privateer-agent 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +61 -20
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +383 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +389 -30
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +511 -46
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. package/src/workflows/store.ts +108 -0
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync }
16
16
  import { hostname, userInfo } from "node:os";
17
17
  import { globalDir, credentialsPath } from "../config/paths.ts";
18
18
  import { isAccountCapCode } from "../engine/errors.ts";
19
+ import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
20
+ import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
19
21
 
20
22
  // Default Privateer API host. NOTE: this is still the legacy "helix" Render
21
23
  // hostname the mobile/web client also points at (client/config/environment.ts);
@@ -118,13 +120,21 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
118
120
  let _account: { accessToken: string } | null = null;
119
121
 
120
122
  export function loadCredentials(): Credentials | null {
121
- if (_cache !== undefined) return _cache;
123
+ // Only a successfully-read credential is memoized. A NEGATIVE result (file absent
124
+ // or unreadable) is deliberately NOT cached: the credential can appear mid-session
125
+ // — the account /login writes credentials.json AFTER the extensions have already
126
+ // booted, and under jiti each extension gets its OWN module instance of this file,
127
+ // so a login's saveCredentials() never reaches another extension's `_cache`. If we
128
+ // memoized the pre-login "absent" as null, that instance would report "not signed
129
+ // in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
130
+ // disk on each miss lets a later call see what a sign-in just wrote.
131
+ if (_cache) return _cache;
122
132
  const path = credentialsPath();
123
- if (!existsSync(path)) return (_cache = null);
133
+ if (!existsSync(path)) return null;
124
134
  try {
125
135
  _cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
126
136
  } catch {
127
- _cache = null;
137
+ return null;
128
138
  }
129
139
  return _cache;
130
140
  }
@@ -145,6 +155,9 @@ export function clearCredentials(): void {
145
155
  } catch {
146
156
  /* nothing to remove */
147
157
  }
158
+ // Drop the pinned account signing key too — it belongs to the account that just
159
+ // signed out; a different account must re-pin its own at link.
160
+ clearAccountSignKey();
148
161
  _cache = null;
149
162
  _child = null;
150
163
  _account = null;
@@ -192,6 +205,19 @@ function notifySessionExpired(): void {
192
205
  }
193
206
  }
194
207
 
208
+ // A server-pushed `session_revoked` relay frame arrived: the account signed this
209
+ // terminal out (from the app's Linked Devices). Treat it exactly like a lazily-
210
+ // detected expiry — wipe the local machine login and announce it via
211
+ // onSessionExpired — but PROMPTLY, while the process is live, instead of waiting
212
+ // for the next authedFetch/launch to hit a 401. The relay owner additionally
213
+ // drops remote access. No-op if we're already signed out, so a duplicate frame
214
+ // (or a frame racing an in-flight /signout) doesn't fire a spurious notice.
215
+ export function handleServerRevoke(): void {
216
+ if (!hasCredentials()) return;
217
+ clearCredentials();
218
+ notifySessionExpired();
219
+ }
220
+
195
221
  // ── Sign-in notification ─────────────────────────────────────────────────────
196
222
  // Fired when a Privateer login completes on this terminal — the cue for the UI to
197
223
  // re-render its header/badge to the signed-in state. It reaches every sign-in entry
@@ -251,7 +277,17 @@ async function postJson(base: string, path: string, body: unknown, init: Request
251
277
  // Step 1: ask the server for a device + user code the human will approve in-app.
252
278
  export async function requestDeviceCode(deviceLabel = defaultDeviceLabel()): Promise<DeviceCode> {
253
279
  const base = serverBaseUrl();
254
- const res = await postJson(base, "/auth/device/code", { deviceLabel });
280
+ // This terminal's public key rides the grant so the app can PIN it on approval
281
+ // (TOFU) — the trust anchor for later sealing secrets (channel tokens) that only
282
+ // this machine can open. Best-effort: if keygen fails we just omit it and the app
283
+ // falls back to terminal-only secret entry rather than blocking login.
284
+ let terminalPub: string | undefined;
285
+ try {
286
+ terminalPub = terminalPublicKeyBase64();
287
+ } catch {
288
+ /* no key → no app-sealed secrets for this terminal; login still proceeds */
289
+ }
290
+ const res = await postJson(base, "/auth/device/code", { deviceLabel, terminalPub });
255
291
  if (!res.ok) {
256
292
  throw new Error(`Couldn't start login (${res.status}). Check your connection or PRIVATEER_SERVER_URL.`);
257
293
  }
@@ -280,9 +316,12 @@ export async function pollForToken(
280
316
  const res = await postJson(base, "/auth/device/token", { device_code: code.device_code });
281
317
 
282
318
  if (res.ok) {
283
- const data = (await res.json()) as Omit<Credentials, "serverBaseUrl">;
284
- const creds: Credentials = { ...data, serverBaseUrl: base };
319
+ const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"> & { accountSignPub?: string };
320
+ const creds: Credentials = { accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user, serverBaseUrl: base };
285
321
  saveCredentials(creds);
322
+ // Pin the account's signing public key (TOFU) so channel-config from the app can
323
+ // be verified as genuinely coming from the account, not a forging relay (F7/F8).
324
+ pinAccountSignKey(data.accountSignPub);
286
325
  notifySignedIn();
287
326
  return creds;
288
327
  }
@@ -0,0 +1,293 @@
1
+ // The transport-agnostic core of the messaging channels — the analog of
2
+ // RemoteBridge for the relay. It owns the policy that must be identical on every
3
+ // platform:
4
+ // - allowlist (who may drive the agent; fail-closed + silent to strangers)
5
+ // - serialization (one turn per conversation at a time; extra messages queue)
6
+ // - redaction (chat platforms are external egress — scrub before send)
7
+ // - chunking (respect the platform's per-message length cap)
8
+ //
9
+ // The agent itself is injected as `runTurn`, so this file stays Pi-free and
10
+ // unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
11
+ // The Pi-backed runner lives in ./run.ts.
12
+
13
+ import type { ChannelAdapter, InboundMessage } from "./types.ts";
14
+
15
+ // A minimal view of the permission request the gate hands us (see
16
+ // src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
17
+ // depend on the gate module.
18
+ export interface ApprovalRequest {
19
+ kind: string;
20
+ title: string;
21
+ detail: string;
22
+ }
23
+
24
+ // Run one agent turn for a conversation. `onText` receives streamed text deltas as
25
+ // they arrive; the bridge buffers/coalesces them. Resolves when the turn is done.
26
+ // `signal` aborts a queued/in-flight turn (e.g. the user sent "/stop").
27
+ export type TurnRunner = (
28
+ chatId: string,
29
+ text: string,
30
+ onText: (delta: string) => void,
31
+ signal: AbortSignal,
32
+ // The triggering user + their role, so the runner (and the gate it drives) can
33
+ // cap a member to read-only regardless of the channel's posture.
34
+ meta: TurnMeta,
35
+ ) => Promise<{ ok: boolean; error?: string }>;
36
+
37
+ export interface TurnMeta {
38
+ userId: string;
39
+ isAdmin: boolean;
40
+ }
41
+
42
+ // A security-audit event. The bridge emits these at authorization-relevant moments;
43
+ // run.ts appends them to an on-disk log. `detail` is redacted before it's emitted.
44
+ export interface AuditEvent {
45
+ at: string;
46
+ event: "prompt" | "approval_request" | "approval_decision" | "interrupt" | "denied";
47
+ chatId: string;
48
+ userId?: string;
49
+ role?: "admin" | "member";
50
+ detail?: string;
51
+ }
52
+
53
+ export interface MessagingBridgeConfig {
54
+ adapter: ChannelAdapter;
55
+ runTurn: TurnRunner;
56
+ // Who may interact at all (admin OR member). False → ignored (fail-closed and
57
+ // SILENT: we don't confirm the bot exists to un-allowlisted senders).
58
+ isAllowed: (msg: InboundMessage) => boolean;
59
+ // Is this user an admin? Admins are governed by the channel posture and are the
60
+ // ONLY users whose yes/no resolves an approval. Members are read-only and can't
61
+ // approve.
62
+ isAdmin: (msg: InboundMessage) => boolean;
63
+ // Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
64
+ redact?: (text: string) => string;
65
+ onLog?: (msg: string) => void;
66
+ // Optional append-only security audit sink.
67
+ onAudit?: (event: AuditEvent) => void;
68
+ }
69
+
70
+ // Stay comfortably under Telegram's 4096-char hard cap (Slack ~40k, Discord 2000 —
71
+ // pick the tightest common bound for the shared path; a platform with a smaller cap
72
+ // can override in its adapter later).
73
+ const MAX_MSG = 1900;
74
+
75
+ // How long to wait for a yes/no approval reply before failing closed (deny).
76
+ const APPROVAL_TIMEOUT_MS = 120_000;
77
+
78
+ const YES = new Set(["yes", "y", "allow", "ok", "okay", "approve", "approved", "👍", "✅"]);
79
+ const NO = new Set(["no", "n", "deny", "denied", "stop", "cancel", "reject", "👎", "❌"]);
80
+
81
+ // Interpret an approval reply. Returns null for anything that isn't a clear
82
+ // yes/no, so the bridge can re-prompt instead of guessing (fail-safe: never treat
83
+ // ambiguous text as allow).
84
+ export function approvalDecision(text: string): "allow" | "deny" | null {
85
+ const t = text.trim().toLowerCase();
86
+ if (YES.has(t)) return "allow";
87
+ if (NO.has(t)) return "deny";
88
+ return null;
89
+ }
90
+
91
+ // The message a user sees when the agent wants to run a gated action.
92
+ export function approvalPrompt(req: ApprovalRequest): string {
93
+ const detail = req.detail.length > 600 ? req.detail.slice(0, 600) + "\n…(truncated)" : req.detail;
94
+ return `⚠️ Approval needed — ${req.title} (${req.kind})\n\n${detail}\n\nReply "yes" to allow or "no" to deny (times out in 2 min).`;
95
+ }
96
+
97
+ // Split text into <=max chunks, preferring newline boundaries so code/paragraphs
98
+ // aren't cut mid-line when possible.
99
+ export function chunkText(text: string, max = MAX_MSG): string[] {
100
+ const out: string[] = [];
101
+ let rest = text;
102
+ while (rest.length > max) {
103
+ let cut = rest.lastIndexOf("\n", max);
104
+ if (cut < max * 0.5) cut = max; // no usable newline in the back half → hard cut
105
+ out.push(rest.slice(0, cut));
106
+ rest = rest.slice(cut).replace(/^\n/, "");
107
+ }
108
+ if (rest) out.push(rest);
109
+ return out;
110
+ }
111
+
112
+ export class MessagingBridge {
113
+ // Per-chat promise tail: each new turn chains onto the previous so turns in the
114
+ // same conversation never interleave (they share one agent session downstream).
115
+ private readonly tails = new Map<string, Promise<void>>();
116
+ // Per-chat abort handle for the in-flight turn, so "/stop" can interrupt it.
117
+ private readonly aborts = new Map<string, AbortController>();
118
+ // Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
119
+ // per chat (turns are serialized and a turn's tool calls are sequential).
120
+ private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
121
+
122
+ constructor(private readonly cfg: MessagingBridgeConfig) {}
123
+
124
+ async start(): Promise<void> {
125
+ await this.cfg.adapter.start((m) => this.onMessage(m));
126
+ this.log(`channel "${this.cfg.adapter.name}" listening`);
127
+ }
128
+
129
+ stop(): void {
130
+ this.cfg.adapter.stop();
131
+ for (const a of this.aborts.values()) a.abort();
132
+ this.aborts.clear();
133
+ // Fail any pending approvals closed so no turn hangs on shutdown.
134
+ for (const resolve of this.approvals.values()) resolve("deny");
135
+ this.approvals.clear();
136
+ }
137
+
138
+ // Ask the user in `chatId` to approve a gated tool action, and await their yes/no
139
+ // reply. Wired to the permission gate's remote approver (see channels/run.ts): the
140
+ // gate suspends the tool until this resolves. Fail-closed — timeout, abort (/stop),
141
+ // or shutdown all resolve to "deny". Public because the gate calls it directly
142
+ // (via an AsyncLocalStorage handle to this bridge + the current chat id).
143
+ requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
144
+ // Only one outstanding approval per chat; deny any stale one first.
145
+ this.approvals.get(chatId)?.("deny");
146
+
147
+ const prompt = approvalPrompt(req);
148
+ const detail = `${req.title}: ${req.detail}`;
149
+ this.cfg.onAudit?.({
150
+ at: new Date().toISOString(),
151
+ event: "approval_request",
152
+ chatId,
153
+ role: "admin", // approvals only arise from admin turns (members are read-only)
154
+ detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
155
+ });
156
+ void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt);
157
+
158
+ return new Promise<"allow" | "deny">((resolve) => {
159
+ let timer: ReturnType<typeof setTimeout>;
160
+ const settle = (decision: "allow" | "deny") => {
161
+ if (this.approvals.get(chatId) !== settle) return; // already settled
162
+ this.approvals.delete(chatId);
163
+ clearTimeout(timer);
164
+ signal?.removeEventListener("abort", onAbort);
165
+ resolve(decision);
166
+ };
167
+ const onAbort = () => {
168
+ void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
169
+ settle("deny");
170
+ };
171
+ timer = setTimeout(() => {
172
+ void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
173
+ settle("deny");
174
+ }, APPROVAL_TIMEOUT_MS);
175
+ this.approvals.set(chatId, settle);
176
+ if (signal) {
177
+ if (signal.aborted) return onAbort();
178
+ signal.addEventListener("abort", onAbort, { once: true });
179
+ }
180
+ });
181
+ }
182
+
183
+ private log(msg: string): void {
184
+ this.cfg.onLog?.(msg);
185
+ }
186
+
187
+ private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
188
+ if (!this.cfg.onAudit) return;
189
+ const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
190
+ this.cfg.onAudit({
191
+ at: new Date().toISOString(),
192
+ event,
193
+ chatId: m.chatId,
194
+ userId: m.userId,
195
+ role: this.cfg.isAdmin(m) ? "admin" : "member",
196
+ detail: red,
197
+ });
198
+ }
199
+
200
+ private onMessage(m: InboundMessage): void {
201
+ const text = m.text?.trim();
202
+ if (!text) return;
203
+
204
+ if (!this.cfg.isAllowed(m)) {
205
+ // Fail closed and stay silent — replying would confirm the bot to strangers.
206
+ this.log(`ignored message from unauthorized user ${m.userId} in chat ${m.chatId}`);
207
+ return;
208
+ }
209
+
210
+ // A pending tool approval in this chat consumes the next message as its answer —
211
+ // BEFORE the per-chat queue, because the turn awaiting approval is itself holding
212
+ // that queue open (routing the reply through the queue would deadlock it).
213
+ const pending = this.approvals.get(m.chatId);
214
+ if (pending) {
215
+ // Only admins may answer an approval. A member's reply is refused (and audited)
216
+ // — never silently treated as a decision.
217
+ if (!this.cfg.isAdmin(m)) {
218
+ this.audit(m, "denied", "non-admin attempted to answer an approval");
219
+ void this.cfg.adapter.sendText(m.chatId, "Only an admin can approve the pending action.");
220
+ return;
221
+ }
222
+ if (text === "/stop") {
223
+ this.audit(m, "approval_decision", "deny (/stop)");
224
+ pending("deny"); // interrupt while awaiting approval → deny it
225
+ return;
226
+ }
227
+ const decision = approvalDecision(text);
228
+ if (decision === null) {
229
+ void this.cfg.adapter.sendText(m.chatId, 'Reply "yes" to allow or "no" to deny the pending action.');
230
+ return;
231
+ }
232
+ this.audit(m, "approval_decision", decision);
233
+ pending(decision);
234
+ return;
235
+ }
236
+
237
+ // "/stop" interrupts the running turn instead of queueing another.
238
+ if (text === "/stop") {
239
+ this.aborts.get(m.chatId)?.abort();
240
+ return;
241
+ }
242
+
243
+ // Serialize per conversation: chain onto this chat's tail.
244
+ const prev = this.tails.get(m.chatId) ?? Promise.resolve();
245
+ const next = prev
246
+ .then(() => this.handle(m))
247
+ .catch((e) => this.log(`turn error: ${e instanceof Error ? e.message : String(e)}`));
248
+ this.tails.set(m.chatId, next);
249
+ // Drop the tail once this was the last queued turn, so the map doesn't grow.
250
+ void next.finally(() => {
251
+ if (this.tails.get(m.chatId) === next) this.tails.delete(m.chatId);
252
+ });
253
+ }
254
+
255
+ private async handle(m: InboundMessage): Promise<void> {
256
+ const { chatId } = m;
257
+ const isAdmin = this.cfg.isAdmin(m);
258
+ this.audit(m, "prompt", m.text.trim().slice(0, 200));
259
+ const ac = new AbortController();
260
+ this.aborts.set(chatId, ac);
261
+ this.cfg.adapter.sendTyping?.(chatId);
262
+
263
+ // Buffer the whole turn's text, then send once (coalesced) — the simplest
264
+ // correct choice. Streaming partial edits back to the channel is a future
265
+ // enhancement; buffering avoids a race between deltas and async sends and
266
+ // keeps this unit-testable without timers.
267
+ let buf = "";
268
+ let result: { ok: boolean; error?: string };
269
+ try {
270
+ result = await this.cfg.runTurn(chatId, m.text.trim(), (d) => (buf += d), ac.signal, {
271
+ userId: m.userId,
272
+ isAdmin,
273
+ });
274
+ } catch (e) {
275
+ result = { ok: false, error: e instanceof Error ? e.message : String(e) };
276
+ } finally {
277
+ this.aborts.delete(chatId);
278
+ }
279
+
280
+ const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
281
+
282
+ // Deliver any text the turn produced (even on error — a partial answer is
283
+ // useful), then an error line if it failed.
284
+ if (body.trim()) {
285
+ for (const chunk of chunkText(body)) await this.cfg.adapter.sendText(chatId, chunk);
286
+ } else if (result.ok) {
287
+ await this.cfg.adapter.sendText(chatId, "✓ done (no text output).");
288
+ }
289
+ if (!result.ok) {
290
+ await this.cfg.adapter.sendText(chatId, `⚠️ ${result.error ?? "the agent hit an error"}`);
291
+ }
292
+ }
293
+ }
@@ -0,0 +1,210 @@
1
+ // Discord channel adapter — templated off the others against the same
2
+ // ChannelAdapter interface. Talks the Discord Gateway (a WebSocket) directly for
3
+ // receive and the REST API for send, so — like Telegram/Slack — it needs NO public
4
+ // inbound endpoint. Zero new dependencies: `ws` is already a dep (relayClient.ts)
5
+ // and REST is fetch.
6
+ //
7
+ // Discord app setup (one-time):
8
+ // - Create a bot, copy its token → `botToken`.
9
+ // - Enable the MESSAGE CONTENT intent (privileged) in the dev portal, else
10
+ // `content` arrives empty.
11
+ // - Invite the bot to your server, or DM it. `allowFrom` lists Discord user ids.
12
+ //
13
+ // Everything above the adapter (allowlist, serialization, redaction, chunking) is
14
+ // MessagingBridge, reused verbatim.
15
+
16
+ import WebSocket from "ws";
17
+ import type { ChannelAdapter, InboundMessage } from "./types.ts";
18
+
19
+ const GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
20
+ const API = "https://discord.com/api/v10";
21
+ const RECONNECT_MS = 3000;
22
+ const SEND_TIMEOUT_MS = 15_000;
23
+
24
+ // Gateway intent bits we need: guild messages, DMs, and (privileged) message
25
+ // content. https://discord.com/developers/docs/topics/gateway#gateway-intents
26
+ const INTENT_GUILD_MESSAGES = 1 << 9;
27
+ const INTENT_DIRECT_MESSAGES = 1 << 12;
28
+ const INTENT_MESSAGE_CONTENT = 1 << 15;
29
+ const DEFAULT_INTENTS = INTENT_GUILD_MESSAGES | INTENT_DIRECT_MESSAGES | INTENT_MESSAGE_CONTENT;
30
+
31
+ interface DiscordMessage {
32
+ channel_id?: string;
33
+ content?: string;
34
+ author?: { id?: string; username?: string; bot?: boolean };
35
+ }
36
+
37
+ // Pure mapping: a MESSAGE_CREATE payload → normalized InboundMessage, or null.
38
+ // Drops messages from ANY bot (`author.bot`, which includes our own) so the agent
39
+ // never talks to itself. Extracted so it's unit-testable without the Gateway.
40
+ export function messageFromDiscord(d: DiscordMessage): InboundMessage | null {
41
+ if (!d || !d.author || d.author.bot) return null;
42
+ if (typeof d.content !== "string" || !d.content || !d.channel_id || !d.author.id) return null;
43
+ return { chatId: d.channel_id, userId: d.author.id, userName: d.author.username, text: d.content };
44
+ }
45
+
46
+ export interface DiscordOptions {
47
+ botToken: string;
48
+ intents?: number;
49
+ fetchImpl?: typeof fetch;
50
+ onLog?: (msg: string) => void;
51
+ }
52
+
53
+ export class DiscordAdapter implements ChannelAdapter {
54
+ readonly name = "discord";
55
+ private readonly token: string;
56
+ private readonly intents: number;
57
+ private readonly fetchImpl: typeof fetch;
58
+ private readonly onLog?: (msg: string) => void;
59
+ private ws?: WebSocket;
60
+ private running = false;
61
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
62
+ private heartbeat?: ReturnType<typeof setInterval>;
63
+ private lastSeq: number | null = null; // last dispatch sequence (for heartbeats)
64
+ private acked = true; // did the server ack our last heartbeat? (zombie detection)
65
+ private onMessage?: (m: InboundMessage) => void;
66
+
67
+ constructor(opts: DiscordOptions) {
68
+ this.token = opts.botToken;
69
+ this.intents = opts.intents ?? DEFAULT_INTENTS;
70
+ this.fetchImpl = opts.fetchImpl ?? fetch;
71
+ this.onLog = opts.onLog;
72
+ }
73
+
74
+ async start(onMessage: (m: InboundMessage) => void): Promise<void> {
75
+ if (this.running) return;
76
+ this.running = true;
77
+ this.onMessage = onMessage;
78
+ this.connect();
79
+ }
80
+
81
+ stop(): void {
82
+ this.running = false;
83
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
84
+ this.clearHeartbeat();
85
+ try {
86
+ this.ws?.close();
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ this.ws = undefined;
91
+ }
92
+
93
+ async sendText(chatId: string, text: string): Promise<void> {
94
+ if (!text) return;
95
+ const res = await this.fetchImpl(`${API}/channels/${chatId}/messages`, {
96
+ method: "POST",
97
+ headers: { authorization: `Bot ${this.token}`, "content-type": "application/json" },
98
+ body: JSON.stringify({ content: text }),
99
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
100
+ });
101
+ if (!res.ok) this.log(`create-message failed: HTTP ${res.status}`);
102
+ }
103
+
104
+ private log(msg: string): void {
105
+ this.onLog?.(`discord: ${msg}`);
106
+ }
107
+
108
+ private connect(): void {
109
+ if (!this.running) return;
110
+ const ws = new WebSocket(GATEWAY);
111
+ this.ws = ws;
112
+ ws.on("message", (raw) => this.handleFrame(ws, raw));
113
+ ws.on("close", () => {
114
+ if (this.ws === ws) this.ws = undefined;
115
+ this.clearHeartbeat();
116
+ if (this.running) this.scheduleReconnect();
117
+ });
118
+ ws.on("error", (err: Error) => this.log(`ws error: ${err?.message ?? err}`));
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ if (!this.running || this.reconnectTimer) return;
123
+ this.reconnectTimer = setTimeout(() => {
124
+ this.reconnectTimer = undefined;
125
+ this.connect();
126
+ }, RECONNECT_MS);
127
+ }
128
+
129
+ private handleFrame(ws: WebSocket, raw: WebSocket.RawData): void {
130
+ let f: { op?: number; t?: string; s?: number | null; d?: any };
131
+ try {
132
+ f = JSON.parse(raw.toString());
133
+ } catch {
134
+ return;
135
+ }
136
+ if (typeof f.s === "number") this.lastSeq = f.s;
137
+ switch (f.op) {
138
+ case 10: // Hello — begin heartbeating, then identify
139
+ this.startHeartbeat(ws, f.d?.heartbeat_interval ?? 41_250);
140
+ this.identify(ws);
141
+ break;
142
+ case 11: // Heartbeat ACK
143
+ this.acked = true;
144
+ break;
145
+ case 1: // server requested a heartbeat now
146
+ this.sendHeartbeat(ws);
147
+ break;
148
+ case 7: // Reconnect
149
+ case 9: // Invalid Session — drop and re-identify fresh (no resume, for simplicity)
150
+ try {
151
+ ws.close();
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ break;
156
+ case 0: // Dispatch
157
+ if (f.t === "MESSAGE_CREATE") {
158
+ const m = messageFromDiscord(f.d);
159
+ if (m) this.onMessage?.(m);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+
165
+ private identify(ws: WebSocket): void {
166
+ this.send(ws, {
167
+ op: 2,
168
+ d: {
169
+ token: this.token,
170
+ intents: this.intents,
171
+ properties: { os: "linux", browser: "privateer", device: "privateer" },
172
+ },
173
+ });
174
+ }
175
+
176
+ private startHeartbeat(ws: WebSocket, intervalMs: number): void {
177
+ this.clearHeartbeat();
178
+ this.acked = true;
179
+ this.heartbeat = setInterval(() => {
180
+ if (!this.acked) {
181
+ // No ACK since the last beat → zombie connection; drop it to reconnect.
182
+ try {
183
+ ws.close();
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ return;
188
+ }
189
+ this.sendHeartbeat(ws);
190
+ }, intervalMs);
191
+ }
192
+
193
+ private sendHeartbeat(ws: WebSocket): void {
194
+ this.acked = false;
195
+ this.send(ws, { op: 1, d: this.lastSeq });
196
+ }
197
+
198
+ private clearHeartbeat(): void {
199
+ if (this.heartbeat) clearInterval(this.heartbeat);
200
+ this.heartbeat = undefined;
201
+ }
202
+
203
+ private send(ws: WebSocket, payload: unknown): void {
204
+ try {
205
+ ws.send(JSON.stringify(payload));
206
+ } catch {
207
+ /* socket dying */
208
+ }
209
+ }
210
+ }