Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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);
}

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);
});
});
Loading
Loading