-
Notifications
You must be signed in to change notification settings - Fork 33
feat(code): add Discord Rich Presence integration #2453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gantoine
wants to merge
14
commits into
main
Choose a base branch
from
feat/discord-presence
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,256
−1
Open
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
0cf8b61
feat(code): add Discord Rich Presence integration
gantoine 29ee4b8
Merge branch 'main' into feat/discord-presence
gantoine 3bb964c
feat(discord-presence): add live Rich Presence preview to settings
gantoine df4563f
test(discord-presence): assert no Discord connection while disabled
gantoine d0f60aa
feat(discord-presence): pause preview timer and show idle when disabled
gantoine 8dbc6ae
refactor(discord-presence): use TypedEventEmitter in the IPC client
gantoine d2ca638
test(discord-presence): parameterise buildActivity and assert truncat…
gantoine ff4dc2c
fix(discord-presence): apply optimistic toggle state before getState …
gantoine ac69e9f
hardcode public app ID
gantoine 345a1f9
chore(discord-presence): document the hardcoded Application ID
gantoine 3a54c11
fix(discord-presence): quiet reconnect logging when Discord isn't run…
gantoine 583a3eb
fix(discord-presence): show "PostHog Code" as the preview card title
gantoine ab5ede8
fix(discord-presence): drop the PostHog wordmark from the preview tile
gantoine fc89762
Merge branch 'main' into feat/discord-presence
gantoine File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /** | ||
| * Discord Rich Presence configuration. | ||
| * | ||
| * The client id identifies the Discord Application whose name and uploaded | ||
| * Rich Presence art (the `*_IMAGE_KEY` assets below) show up on a user's | ||
| * profile. Register an application at https://discord.com/developers and wire | ||
| * its id through `VITE_DISCORD_CLIENT_ID` (see `.env.example`). Until a real id | ||
| * is configured the service stays dormant and never opens a socket. | ||
| */ | ||
| export function getDiscordClientId(): string { | ||
| return process.env.VITE_DISCORD_CLIENT_ID ?? ""; | ||
| } | ||
|
|
||
| /** Asset keys uploaded under the Discord app's Rich Presence → Art Assets. */ | ||
| export const LARGE_IMAGE_KEY = "posthog_logo"; | ||
| export const SMALL_IMAGE_RUNNING = "agent_running"; | ||
| export const SMALL_IMAGE_IDLE = "posthog_idle"; | ||
|
|
||
| /** How long to wait before retrying a dropped/absent Discord connection. */ | ||
| export const RECONNECT_INTERVAL_MS = 15_000; | ||
|
|
||
| /** | ||
| * Minimum spacing between SET_ACTIVITY frames. Discord rate-limits presence | ||
| * updates (~5 per 20s); we coalesce to one update per this window with a | ||
| * trailing flush so the final state always lands. | ||
| */ | ||
| export const MIN_UPDATE_INTERVAL_MS = 15_000; | ||
|
|
||
| /** Discord rejects activity strings shorter than 2 or longer than 128 chars. */ | ||
| export const MAX_FIELD_LENGTH = 128; |
216 changes: 216 additions & 0 deletions
216
apps/code/src/main/services/discord-presence/discord-ipc.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| import { randomUUID } from "node:crypto"; | ||
| import { EventEmitter } from "node:events"; | ||
| import net from "node:net"; | ||
| import path from "node:path"; | ||
| import { logger } from "../../utils/logger"; | ||
|
|
||
| const log = logger.scope("discord-ipc"); | ||
|
|
||
| /** Discord local-IPC opcodes (see Discord RPC transport docs). */ | ||
| const OPCODE = { | ||
| HANDSHAKE: 0, | ||
| FRAME: 1, | ||
| CLOSE: 2, | ||
| PING: 3, | ||
| PONG: 4, | ||
| } as const; | ||
|
|
||
| /** The Rich Presence activity payload sent in a SET_ACTIVITY frame. */ | ||
| export interface DiscordActivity { | ||
| details?: string; | ||
| state?: string; | ||
| timestamps?: { start?: number; end?: number }; | ||
| assets?: { | ||
| large_image?: string; | ||
| large_text?: string; | ||
| small_image?: string; | ||
| small_text?: string; | ||
| }; | ||
| instance?: boolean; | ||
| } | ||
|
|
||
| interface DiscordIpcClientEvents { | ||
| ready: () => void; | ||
| disconnect: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Minimal Discord local-IPC client — just enough of the protocol to perform | ||
| * the handshake and push SET_ACTIVITY frames, modelled the same way VS Code's | ||
| * Discord integrations talk to the desktop client. It connects to the first | ||
| * reachable `discord-ipc-{0..9}` socket and emits `ready` once the client | ||
| * acknowledges the handshake, `disconnect` when the socket drops. | ||
| * | ||
| * It performs no reconnection of its own; the owning service decides when to | ||
| * retry so the policy lives in one place. | ||
| */ | ||
| export class DiscordIpcClient extends EventEmitter { | ||
| private socket: net.Socket | null = null; | ||
| private readBuffer = Buffer.alloc(0); | ||
| private ready = false; | ||
|
|
||
| constructor(private readonly clientId: string) { | ||
| super(); | ||
| } | ||
|
|
||
| override on<K extends keyof DiscordIpcClientEvents>( | ||
| event: K, | ||
| listener: DiscordIpcClientEvents[K], | ||
| ): this { | ||
| return super.on(event, listener); | ||
| } | ||
|
|
||
| override emit<K extends keyof DiscordIpcClientEvents>(event: K): boolean { | ||
| return super.emit(event); | ||
| } | ||
|
|
||
| isReady(): boolean { | ||
| return this.ready; | ||
| } | ||
|
|
||
| /** Attempt to connect, trying each candidate socket path in turn. */ | ||
| connect(): void { | ||
| if (this.socket) return; | ||
| this.tryConnect(this.candidatePaths(), 0); | ||
| } | ||
|
|
||
| /** Tear down without emitting — used when the owner intentionally stops. */ | ||
| destroy(): void { | ||
| if (this.socket) { | ||
| try { | ||
| this.socket.destroy(); | ||
| } catch { | ||
| // best effort | ||
| } | ||
| this.socket = null; | ||
| } | ||
| this.ready = false; | ||
| this.readBuffer = Buffer.alloc(0); | ||
| this.removeAllListeners(); | ||
| } | ||
|
|
||
| setActivity(activity: DiscordActivity | null): void { | ||
| if (!this.socket || !this.ready) return; | ||
| this.write(OPCODE.FRAME, { | ||
| cmd: "SET_ACTIVITY", | ||
| args: { pid: process.pid, activity: activity ?? undefined }, | ||
| nonce: randomUUID(), | ||
| }); | ||
| } | ||
|
|
||
| private tryConnect(paths: string[], index: number): void { | ||
| if (index >= paths.length) { | ||
| log.debug("No reachable Discord IPC socket"); | ||
| super.emit("disconnect"); | ||
| return; | ||
| } | ||
|
|
||
| const sock = net.createConnection(paths[index]); | ||
|
|
||
| const onError = () => { | ||
| sock.removeAllListeners(); | ||
| sock.destroy(); | ||
| this.tryConnect(paths, index + 1); | ||
| }; | ||
|
|
||
| sock.once("error", onError); | ||
| sock.once("connect", () => { | ||
| sock.removeListener("error", onError); | ||
| this.socket = sock; | ||
| sock.on("data", (chunk) => this.onData(chunk)); | ||
| sock.on("error", () => { | ||
| // Surfaced via the subsequent "close" event. | ||
| }); | ||
| sock.on("close", () => this.handleClose()); | ||
| this.write(OPCODE.HANDSHAKE, { v: 1, client_id: this.clientId }); | ||
| }); | ||
| } | ||
|
|
||
| private candidatePaths(): string[] { | ||
| const ids = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; | ||
|
|
||
| if (process.platform === "win32") { | ||
| return ids.map((id) => `\\\\?\\pipe\\discord-ipc-${id}`); | ||
| } | ||
|
|
||
| const base = | ||
| process.env.XDG_RUNTIME_DIR || | ||
| process.env.TMPDIR || | ||
| process.env.TMP || | ||
| process.env.TEMP || | ||
| "/tmp"; | ||
| const root = base.replace(/\/$/, ""); | ||
| // Discord may live at the temp root or under a sandbox subdir (Snap/Flatpak). | ||
| const dirs = [ | ||
| root, | ||
| path.join(root, "snap.discord"), | ||
| path.join(root, "app", "com.discordapp.Discord"), | ||
| path.join(root, "app", "com.discordapp.DiscordCanary"), | ||
| ]; | ||
| return dirs.flatMap((dir) => | ||
| ids.map((id) => path.join(dir, `discord-ipc-${id}`)), | ||
| ); | ||
| } | ||
|
|
||
| private onData(chunk: Buffer): void { | ||
| this.readBuffer = Buffer.concat([this.readBuffer, chunk]); | ||
| // Frames are [Int32LE opcode][Int32LE length][JSON body]. | ||
| while (this.readBuffer.length >= 8) { | ||
| const op = this.readBuffer.readInt32LE(0); | ||
| const len = this.readBuffer.readInt32LE(4); | ||
| if (this.readBuffer.length < 8 + len) break; | ||
| const body = this.readBuffer.subarray(8, 8 + len); | ||
| this.readBuffer = this.readBuffer.subarray(8 + len); | ||
| this.handleFrame(op, body); | ||
| } | ||
| } | ||
|
|
||
| private handleFrame(op: number, body: Buffer): void { | ||
| if (op === OPCODE.PING) { | ||
| this.write(OPCODE.PONG, this.parse(body)); | ||
| return; | ||
| } | ||
| if (op === OPCODE.CLOSE) { | ||
| this.handleClose(); | ||
| return; | ||
| } | ||
| if (op === OPCODE.FRAME) { | ||
| const msg = this.parse(body) as { cmd?: string; evt?: string } | null; | ||
| if (msg?.cmd === "DISPATCH" && msg.evt === "READY") { | ||
| this.ready = true; | ||
| log.info("Discord IPC handshake complete"); | ||
| super.emit("ready"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private handleClose(): void { | ||
| if (!this.socket) return; | ||
| this.ready = false; | ||
| this.readBuffer = Buffer.alloc(0); | ||
| try { | ||
| this.socket.destroy(); | ||
| } catch { | ||
| // best effort | ||
| } | ||
| this.socket = null; | ||
| super.emit("disconnect"); | ||
| } | ||
|
|
||
| private write(op: number, payload: unknown): void { | ||
| if (!this.socket) return; | ||
| const json = Buffer.from(JSON.stringify(payload), "utf8"); | ||
| const header = Buffer.alloc(8); | ||
| header.writeInt32LE(op, 0); | ||
| header.writeInt32LE(json.length, 4); | ||
| this.socket.write(Buffer.concat([header, json])); | ||
| } | ||
|
|
||
| private parse(body: Buffer): unknown { | ||
| try { | ||
| return JSON.parse(body.toString("utf8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
84 changes: 84 additions & 0 deletions
84
apps/code/src/main/services/discord-presence/presence-format.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { buildActivity } from "./presence-format"; | ||
| import type { PresenceIntent } from "./schemas"; | ||
|
|
||
| const STARTED_AT = 1_700_000_000_000; | ||
|
|
||
| const baseOptions = { | ||
| showTaskTitle: false, | ||
| showRepoName: false, | ||
| startedAt: STARTED_AT, | ||
| }; | ||
|
|
||
| const activeIntent: PresenceIntent = { | ||
| hasActiveTask: true, | ||
| taskTitle: "Add Discord presence", | ||
| repoName: "posthog/code", | ||
| agentRunning: true, | ||
| }; | ||
|
|
||
| describe("buildActivity", () => { | ||
| it("hides the task title and repo name by default (privacy-first)", () => { | ||
| const activity = buildActivity(activeIntent, baseOptions); | ||
| expect(activity.details).toBe("Working on a task"); | ||
| expect(activity.state).toBe("agent running"); | ||
| }); | ||
|
|
||
| it("includes the task title only when opted in", () => { | ||
| const activity = buildActivity(activeIntent, { | ||
| ...baseOptions, | ||
| showTaskTitle: true, | ||
| }); | ||
| expect(activity.details).toBe('Working on "Add Discord presence"'); | ||
| }); | ||
|
|
||
| it("includes the repo name only when opted in", () => { | ||
| const activity = buildActivity(activeIntent, { | ||
| ...baseOptions, | ||
| showRepoName: true, | ||
| }); | ||
| expect(activity.state).toBe("posthog/code · agent running"); | ||
| }); | ||
|
|
||
| it("reflects review status with the idle badge when the agent is idle on a task", () => { | ||
| const activity = buildActivity( | ||
| { ...activeIntent, agentRunning: false }, | ||
| { ...baseOptions, showRepoName: true }, | ||
| ); | ||
| expect(activity.state).toBe("posthog/code · reviewing"); | ||
| expect(activity.assets?.small_image).toBe("posthog_idle"); | ||
| expect(activity.assets?.small_text).toBe("Reviewing"); | ||
| }); | ||
|
|
||
| it("falls back to an idle/browsing presence with the idle badge when no task is focused", () => { | ||
| const activity = buildActivity( | ||
| { | ||
| hasActiveTask: false, | ||
| taskTitle: null, | ||
| repoName: null, | ||
| agentRunning: false, | ||
| }, | ||
| { ...baseOptions, showTaskTitle: true, showRepoName: true }, | ||
| ); | ||
| expect(activity.details).toBe("Idle"); | ||
| expect(activity.state).toBe("browsing"); | ||
| expect(activity.assets?.small_image).toBe("posthog_idle"); | ||
| expect(activity.assets?.small_text).toBe("Idle"); | ||
| }); | ||
|
|
||
| it("surfaces the running indicator asset while the agent works", () => { | ||
| const activity = buildActivity(activeIntent, baseOptions); | ||
| expect(activity.assets?.small_image).toBe("agent_running"); | ||
| expect(activity.timestamps?.start).toBe(STARTED_AT); | ||
| }); | ||
|
|
||
| it("truncates over-long titles to Discord's field limit", () => { | ||
| const longTitle = "x".repeat(200); | ||
| const activity = buildActivity( | ||
| { ...activeIntent, taskTitle: longTitle }, | ||
| { ...baseOptions, showTaskTitle: true }, | ||
| ); | ||
| expect(activity.details).toBeDefined(); | ||
| expect((activity.details as string).length).toBeLessThanOrEqual(128); | ||
| }); | ||
|
gantoine marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
gantoine marked this conversation as resolved.
Outdated
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.