Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ APPLE_CODESIGN_KEYCHAIN_PASSWORD="xxx"
VITE_POSTHOG_API_KEY=xxx
VITE_POSTHOG_API_HOST=xxx
VITE_POSTHOG_UI_HOST=xxx

# Discord Rich Presence. Upload Rich Presence art assets named
# "posthog_logo", "agent_running", and "posthog_idle" in the Discord app.
VITE_DISCORD_CLIENT_ID=
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
import { DeepLinkService } from "../services/deep-link/service";
import { DiscordPresenceService } from "../services/discord-presence/service";
import { EnrichmentService } from "../services/enrichment/service";
import { EnvironmentService } from "../services/environment/service";
import { ExternalAppsService } from "../services/external-apps/service";
Expand Down Expand Up @@ -117,6 +118,7 @@ container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService);
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
container.bind(MAIN_TOKENS.DiscordPresenceService).to(DiscordPresenceService);
container.bind(MAIN_TOKENS.EnrichmentService).to(EnrichmentService);
container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService);
container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const MAIN_TOKENS = Object.freeze({
CloudTaskService: Symbol.for("Main.CloudTaskService"),
ConnectivityService: Symbol.for("Main.ConnectivityService"),
ContextMenuService: Symbol.for("Main.ContextMenuService"),
DiscordPresenceService: Symbol.for("Main.DiscordPresenceService"),

ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
LlmGatewayService: Symbol.for("Main.LlmGatewayService"),
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MAIN_TOKENS } from "./di/tokens";
import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
import type { AppLifecycleService } from "./services/app-lifecycle/service";
import type { AuthService } from "./services/auth/service";
import type { DiscordPresenceService } from "./services/discord-presence/service";
import type { ExternalAppsService } from "./services/external-apps/service";
import type { GitHubIntegrationService } from "./services/github-integration/service";
import type { InboxLinkService } from "./services/inbox-link/service";
Expand Down Expand Up @@ -156,6 +157,8 @@ async function initializeServices(): Promise<void> {
container.get<SlackIntegrationService>(MAIN_TOKENS.SlackIntegrationService);
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);
// Eagerly start the Discord presence service so it connects when enabled.
container.get<DiscordPresenceService>(MAIN_TOKENS.DiscordPresenceService);

await authService.initialize();

Expand Down
30 changes: 30 additions & 0 deletions apps/code/src/main/services/discord-presence/constants.ts
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 apps/code/src/main/services/discord-presence/discord-ipc.ts
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);
}
Comment thread
gantoine marked this conversation as resolved.
Outdated

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;
}
}
}
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);
});
Comment thread
gantoine marked this conversation as resolved.
Outdated
});
Comment thread
gantoine marked this conversation as resolved.
Outdated
Loading
Loading