privateer-agent 0.9.2 → 0.10.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.
@@ -2,15 +2,25 @@
2
2
  // RemoteBridge for the relay. It owns the policy that must be identical on every
3
3
  // platform:
4
4
  // - allowlist (who may drive the agent; fail-closed + silent to strangers)
5
+ // - mention gate (in a shared room, only act when addressed)
5
6
  // - serialization (one turn per conversation at a time; extra messages queue)
6
7
  // - redaction (chat platforms are external egress — scrub before send)
7
- // - chunking (respect the platform's per-message length cap)
8
+ // - chunking (respect the platform's per-message byte cap)
9
+ //
10
+ // ⚠️ SECURITY — DO NOT RESOLVE QUOTED PARENTS. An inbound message may carry a
11
+ // `replyToId`, and it is tempting to fetch that parent and inline its text into the
12
+ // prompt for context. Don't. The allowlist gates the SENDER, not the room: on a
13
+ // shared platform (Buzz channels, a Discord guild, a Slack channel) anyone can post,
14
+ // and resolving quoted parents would let a non-allowlisted participant inject
15
+ // arbitrary text into the agent's context simply by getting an admin to reply to
16
+ // them. Threading ids are for ADDRESSING replies outward, never for pulling content
17
+ // inward.
8
18
  //
9
19
  // The agent itself is injected as `runTurn`, so this file stays Pi-free and
10
20
  // unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
11
21
  // The Pi-backed runner lives in ./run.ts.
12
22
 
13
- import type { ChannelAdapter, InboundMessage } from "./types.ts";
23
+ import type { ChannelAdapter, InboundMessage, SendOptions } from "./types.ts";
14
24
 
15
25
  // A minimal view of the permission request the gate hands us (see
16
26
  // src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
@@ -48,8 +58,19 @@ export interface AuditEvent {
48
58
  userId?: string;
49
59
  role?: "admin" | "member";
50
60
  detail?: string;
61
+ /** The platform id of the triggering message, when the platform has one — makes an
62
+ * audit line traceable back to the exact message in the channel. Note we
63
+ * deliberately do NOT log `mentions`: other participants' ids are third-party PII
64
+ * and don't belong in an append-only file. */
65
+ messageId?: string;
51
66
  }
52
67
 
68
+ /** Whether the agent answers everything in a conversation, or only when addressed.
69
+ * off — every allowlisted message runs a turn (the original behavior)
70
+ * mention — only when the message @-mentions this agent
71
+ * mention-or-dm — as above, plus any 1:1 conversation */
72
+ export type MentionGate = "off" | "mention" | "mention-or-dm";
73
+
53
74
  export interface MessagingBridgeConfig {
54
75
  adapter: ChannelAdapter;
55
76
  runTurn: TurnRunner;
@@ -62,15 +83,19 @@ export interface MessagingBridgeConfig {
62
83
  isAdmin: (msg: InboundMessage) => boolean;
63
84
  // Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
64
85
  redact?: (text: string) => string;
86
+ // Should the agent answer everything, or only when addressed? Defaults to "off",
87
+ // preserving the original behavior for every existing platform.
88
+ mentionGate?: MentionGate;
65
89
  onLog?: (msg: string) => void;
66
90
  // Optional append-only security audit sink.
67
91
  onAudit?: (event: AuditEvent) => void;
68
92
  }
69
93
 
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;
94
+ // Fallback per-message ceiling in BYTES, used only when an adapter doesn't declare
95
+ // its own `maxMessageBytes`. It is the tightest common bound (Discord's 2000-char
96
+ // cap, with headroom), so it is safe everywhere and generous nowhere — adapters
97
+ // should declare the real figure. See channels/platforms.ts for the per-platform table.
98
+ const DEFAULT_MAX_BYTES = 1900;
74
99
 
75
100
  // How long to wait for a yes/no approval reply before failing closed (deny).
76
101
  const APPROVAL_TIMEOUT_MS = 120_000;
@@ -94,21 +119,80 @@ export function approvalPrompt(req: ApprovalRequest): string {
94
119
  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
120
  }
96
121
 
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[] {
122
+ // The longest prefix of `s` that fits in `maxBytes` of UTF-8, cut only on code-point
123
+ // boundaries. Iterating the string yields whole code points, so a surrogate pair is
124
+ // never split down the middle into two replacement characters.
125
+ function sliceByBytes(s: string, maxBytes: number): string {
126
+ if (Buffer.byteLength(s) <= maxBytes) return s;
127
+ let bytes = 0;
128
+ let out = "";
129
+ for (const ch of s) {
130
+ const b = Buffer.byteLength(ch);
131
+ if (bytes + b > maxBytes) break;
132
+ bytes += b;
133
+ out += ch;
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // Split text into chunks that each fit in `maxBytes`, preferring newline boundaries
139
+ // so code/paragraphs aren't cut mid-line when possible.
140
+ //
141
+ // The cap is in BYTES, not characters, because every platform's limit ultimately is:
142
+ // a reply of emoji or CJK is up to 4x longer on the wire than its `.length` suggests,
143
+ // and measuring in characters silently overshoots and gets the message rejected.
144
+ export function chunkText(text: string, maxBytes = DEFAULT_MAX_BYTES): string[] {
100
145
  const out: string[] = [];
101
146
  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/, "");
147
+ while (Buffer.byteLength(rest) > maxBytes) {
148
+ let head = sliceByBytes(rest, maxBytes);
149
+ // A single code point wider than the cap would otherwise loop forever.
150
+ if (!head) head = [...rest][0] ?? "";
151
+ if (!head) break;
152
+ const nl = head.lastIndexOf("\n");
153
+ if (nl > 0 && nl >= head.length * 0.5) head = head.slice(0, nl); // usable newline in the back half
154
+ out.push(head);
155
+ rest = rest.slice(head.length).replace(/^\n/, "");
107
156
  }
108
157
  if (rest) out.push(rest);
109
158
  return out;
110
159
  }
111
160
 
161
+ /**
162
+ * Should this message run a turn at all?
163
+ *
164
+ * In a 1:1 chat "answer everything" is right. In a shared room with other humans it
165
+ * is unusable noise — and worse, it means every passing remark becomes a prompt. So
166
+ * a channel can require being addressed.
167
+ *
168
+ * Fails CLOSED on adapters that can't report mentions: if the gate is on and
169
+ * `mentionsMe` is absent, the agent stays quiet rather than answering everything.
170
+ * Enabling a gate a platform can't honour should silence the bot, not defeat the gate.
171
+ */
172
+ export function passesMentionGate(m: InboundMessage, mode: MentionGate | undefined): boolean {
173
+ if (!mode || mode === "off") return true;
174
+ if (m.mentionsMe) return true;
175
+ return mode === "mention-or-dm" && m.isDirect === true;
176
+ }
177
+
178
+ /**
179
+ * Render attachments into the prompt text.
180
+ *
181
+ * v1 is a text footer: `TurnRunner` carries only a string, so real multimodal means
182
+ * widening that signature and routing bytes through src/util/attachmentStore.ts.
183
+ * This is the seam where that would go. Deliberately out of scope for now — the URL
184
+ * is enough for an agent with web/read tools to act on.
185
+ */
186
+ export function promptWithAttachments(m: InboundMessage): string {
187
+ const text = m.text.trim();
188
+ if (!m.attachments?.length) return text;
189
+ const lines = m.attachments.map((a) => {
190
+ const bits = [a.mediaType ?? "file", a.name, a.url ?? a.id].filter(Boolean);
191
+ return `[attached: ${bits.join(" — ")}]`;
192
+ });
193
+ return text ? `${text}\n\n${lines.join("\n")}` : lines.join("\n");
194
+ }
195
+
112
196
  export class MessagingBridge {
113
197
  // Per-chat promise tail: each new turn chains onto the previous so turns in the
114
198
  // same conversation never interleave (they share one agent session downstream).
@@ -118,6 +202,15 @@ export class MessagingBridge {
118
202
  // Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
119
203
  // per chat (turns are serialized and a turn's tool calls are sequential).
120
204
  private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
205
+ // Where to attach outbound messages in each chat, so replies, approval prompts and
206
+ // error lines all land in the thread that triggered them rather than at the bottom
207
+ // of the room. Empty for platforms without threading — `sendReply` then degrades to
208
+ // a plain send.
209
+ private readonly lastInbound = new Map<string, { messageId?: string; threadRootId?: string }>();
210
+ // Set by stop(). Aborting a turn signals the GATE, not Pi's session.prompt() — so
211
+ // an in-flight turn can still resolve after teardown and try to send its answer
212
+ // through a closed adapter. Every send path checks this first.
213
+ private stopped = false;
121
214
 
122
215
  constructor(private readonly cfg: MessagingBridgeConfig) {}
123
216
 
@@ -126,13 +219,16 @@ export class MessagingBridge {
126
219
  this.log(`channel "${this.cfg.adapter.name}" listening`);
127
220
  }
128
221
 
129
- stop(): void {
130
- this.cfg.adapter.stop();
222
+ async stop(): Promise<void> {
223
+ this.stopped = true;
131
224
  for (const a of this.aborts.values()) a.abort();
132
225
  this.aborts.clear();
133
226
  // Fail any pending approvals closed so no turn hangs on shutdown.
134
227
  for (const resolve of this.approvals.values()) resolve("deny");
135
228
  this.approvals.clear();
229
+ // Last: an adapter owning a listening socket resolves only once the port is
230
+ // genuinely free, which a targeted platform restart depends on.
231
+ await this.cfg.adapter.stop();
136
232
  }
137
233
 
138
234
  // Ask the user in `chatId` to approve a gated tool action, and await their yes/no
@@ -141,6 +237,9 @@ export class MessagingBridge {
141
237
  // or shutdown all resolve to "deny". Public because the gate calls it directly
142
238
  // (via an AsyncLocalStorage handle to this bridge + the current chat id).
143
239
  requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
240
+ // Torn down: nobody is listening for the reply, so fail closed immediately rather
241
+ // than prompting into a dead channel and waiting out the 2-minute timeout.
242
+ if (this.stopped) return Promise.resolve("deny");
144
243
  // Only one outstanding approval per chat; deny any stale one first.
145
244
  this.approvals.get(chatId)?.("deny");
146
245
 
@@ -153,7 +252,10 @@ export class MessagingBridge {
153
252
  role: "admin", // approvals only arise from admin turns (members are read-only)
154
253
  detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
155
254
  });
156
- void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt);
255
+ // Threaded alongside the turn that raised it, so a busy room doesn't scatter the
256
+ // question away from the request that prompted it.
257
+ const opts = this.sendOptions(chatId);
258
+ void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt, opts);
157
259
 
158
260
  return new Promise<"allow" | "deny">((resolve) => {
159
261
  let timer: ReturnType<typeof setTimeout>;
@@ -165,11 +267,11 @@ export class MessagingBridge {
165
267
  resolve(decision);
166
268
  };
167
269
  const onAbort = () => {
168
- void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
270
+ void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.", opts);
169
271
  settle("deny");
170
272
  };
171
273
  timer = setTimeout(() => {
172
- void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
274
+ void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.", opts);
173
275
  settle("deny");
174
276
  }, APPROVAL_TIMEOUT_MS);
175
277
  this.approvals.set(chatId, settle);
@@ -184,6 +286,14 @@ export class MessagingBridge {
184
286
  this.cfg.onLog?.(msg);
185
287
  }
186
288
 
289
+ // Where an outbound message for this chat should attach. Undefined on platforms
290
+ // that don't carry message ids, which every adapter accepts as "just send it".
291
+ private sendOptions(chatId: string): SendOptions | undefined {
292
+ const last = this.lastInbound.get(chatId);
293
+ if (!last?.messageId) return undefined;
294
+ return { replyTo: last.messageId, threadRoot: last.threadRootId ?? last.messageId };
295
+ }
296
+
187
297
  private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
188
298
  if (!this.cfg.onAudit) return;
189
299
  const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
@@ -194,10 +304,12 @@ export class MessagingBridge {
194
304
  userId: m.userId,
195
305
  role: this.cfg.isAdmin(m) ? "admin" : "member",
196
306
  detail: red,
307
+ messageId: m.messageId,
197
308
  });
198
309
  }
199
310
 
200
311
  private onMessage(m: InboundMessage): void {
312
+ if (this.stopped) return; // torn down; an adapter may still flush a queued frame
201
313
  const text = m.text?.trim();
202
314
  if (!text) return;
203
315
 
@@ -240,6 +352,17 @@ export class MessagingBridge {
240
352
  return;
241
353
  }
242
354
 
355
+ // The mention gate sits HERE and the position is load-bearing. It must come
356
+ // after the approval interception and after "/stop", because neither of those
357
+ // carries an @mention: gating an approval reply would leave the waiting turn
358
+ // hanging until its 2-minute fail-closed timeout, and gating "/stop" would make
359
+ // the channel uninterruptible.
360
+ if (!passesMentionGate(m, this.cfg.mentionGate)) return;
361
+
362
+ // Remember where to attach the reply. Recorded only for messages that actually
363
+ // run a turn, so an approval answer can't retarget the thread mid-conversation.
364
+ this.lastInbound.set(m.chatId, { messageId: m.messageId, threadRootId: m.threadRootId });
365
+
243
366
  // Serialize per conversation: chain onto this chat's tail.
244
367
  const prev = this.tails.get(m.chatId) ?? Promise.resolve();
245
368
  const next = prev
@@ -248,7 +371,10 @@ export class MessagingBridge {
248
371
  this.tails.set(m.chatId, next);
249
372
  // Drop the tail once this was the last queued turn, so the map doesn't grow.
250
373
  void next.finally(() => {
251
- if (this.tails.get(m.chatId) === next) this.tails.delete(m.chatId);
374
+ if (this.tails.get(m.chatId) === next) {
375
+ this.tails.delete(m.chatId);
376
+ this.lastInbound.delete(m.chatId); // the reply is out; nothing left to attach to
377
+ }
252
378
  });
253
379
  }
254
380
 
@@ -267,7 +393,7 @@ export class MessagingBridge {
267
393
  let buf = "";
268
394
  let result: { ok: boolean; error?: string };
269
395
  try {
270
- result = await this.cfg.runTurn(chatId, m.text.trim(), (d) => (buf += d), ac.signal, {
396
+ result = await this.cfg.runTurn(chatId, promptWithAttachments(m), (d) => (buf += d), ac.signal, {
271
397
  userId: m.userId,
272
398
  isAdmin,
273
399
  });
@@ -279,15 +405,35 @@ export class MessagingBridge {
279
405
 
280
406
  const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
281
407
 
408
+ // The turn may have outlived stop() (aborting reaches the gate, not Pi). Drop the
409
+ // answer rather than pushing it through a closed adapter — on a targeted restart
410
+ // the replacement bridge is already serving this chat.
411
+ if (this.stopped) {
412
+ this.log(`dropped a reply for chat ${chatId} — the channel was stopped mid-turn`);
413
+ return;
414
+ }
415
+
416
+ // Send in the thread the request came from, chaining each chunk beneath the
417
+ // previous one when the adapter tells us the id it just wrote — so a long,
418
+ // multi-chunk answer reads as one conversation rather than N siblings.
419
+ const opts = this.sendOptions(chatId);
420
+ const root = opts?.threadRoot;
421
+ let parent = opts?.replyTo;
422
+ const send = async (t: string) => {
423
+ const id = await this.cfg.adapter.sendText(chatId, t, parent ? { replyTo: parent, threadRoot: root } : undefined);
424
+ if (typeof id === "string" && id) parent = id;
425
+ };
426
+
282
427
  // Deliver any text the turn produced (even on error — a partial answer is
283
428
  // useful), then an error line if it failed.
284
429
  if (body.trim()) {
285
- for (const chunk of chunkText(body)) await this.cfg.adapter.sendText(chatId, chunk);
430
+ const maxBytes = this.cfg.adapter.maxMessageBytes ?? DEFAULT_MAX_BYTES;
431
+ for (const chunk of chunkText(body, maxBytes)) await send(chunk);
286
432
  } else if (result.ok) {
287
- await this.cfg.adapter.sendText(chatId, "✓ done (no text output).");
433
+ await send("✓ done (no text output).");
288
434
  }
289
435
  if (!result.ok) {
290
- await this.cfg.adapter.sendText(chatId, `⚠️ ${result.error ?? "the agent hit an error"}`);
436
+ await send(`⚠️ ${result.error ?? "the agent hit an error"}`);
291
437
  }
292
438
  }
293
439
  }
@@ -0,0 +1,64 @@
1
+ // The per-platform facts that BOTH the channels runtime and the app-facing config
2
+ // control need to agree on: which platforms exist, which of their fields are
3
+ // secrets, how big a single message may be, and whether a given config block has
4
+ // enough in it to start.
5
+ //
6
+ // This file is deliberately the ONLY place those four things are declared. Before
7
+ // it existed they were duplicated between the `startChannel` if-chain in run.ts and
8
+ // the CHANNEL_PLATFORMS/SECRET_FIELDS constants in remote/channelsControl.ts, with
9
+ // a "keep in sync" comment as the only thing holding them together. Adding a
10
+ // platform now means editing this file plus wiring one adapter.
11
+ //
12
+ // Pi-free and dependency-free on purpose: channelsControl.ts runs inside the harbor
13
+ // (which must be able to configure a channel that has never run), while the runtime
14
+ // runs wherever the bots live. Both import this; neither imports the other.
15
+
16
+ export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
17
+ export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
18
+
19
+ export function isChannelPlatform(v: unknown): v is ChannelPlatform {
20
+ return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
21
+ }
22
+
23
+ // The secret (never-echoed) fields per platform — the union of the credentials each
24
+ // platform needs to START. `secretsSet` in the app reports the PRESENCE of these by
25
+ // name; their values never cross the wire back to the app.
26
+ export const SECRET_FIELDS: Record<ChannelPlatform, readonly string[]> = {
27
+ telegram: ["botToken"],
28
+ slack: ["appToken", "botToken"],
29
+ discord: ["botToken"],
30
+ whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
31
+ };
32
+
33
+ // Per-message size ceiling, in BYTES of UTF-8 content, sitting just under each
34
+ // platform's documented hard cap so a multi-byte reply can't overshoot:
35
+ // telegram 4096 chars · slack ~40k · discord 2000 chars · whatsapp 4096 chars
36
+ // The bridge reads the adapter's own `maxMessageBytes` at send time; this table is
37
+ // what the adapters (and outbound delivery, which has no adapter instance in hand)
38
+ // are configured from.
39
+ export const MAX_MESSAGE_BYTES: Record<ChannelPlatform, number> = {
40
+ telegram: 4000,
41
+ slack: 39_000,
42
+ discord: 1900,
43
+ whatsapp: 3900,
44
+ };
45
+
46
+ // Does this config block carry everything the platform needs to start? Mirrors the
47
+ // conditions the runtime uses to decide whether to construct an adapter at all.
48
+ //
49
+ // NOTE this checks CREDENTIALS only. The other fail-closed requirement — at least
50
+ // one admin or member — is deliberately NOT here: it's shared across every platform
51
+ // and is enforced once, by the runtime and by channelsControl.save().
52
+ export function startableFrom(platform: ChannelPlatform, block: any): boolean {
53
+ if (!block) return false;
54
+ switch (platform) {
55
+ case "telegram":
56
+ return !!block.botToken;
57
+ case "slack":
58
+ return !!block.appToken && !!block.botToken;
59
+ case "discord":
60
+ return !!block.botToken;
61
+ case "whatsapp":
62
+ return !!block.phoneNumberId && !!block.accessToken && !!block.verifyToken;
63
+ }
64
+ }
@@ -106,6 +106,13 @@ function normalizePosture(v: unknown): Posture | undefined {
106
106
  }
107
107
 
108
108
  async function main() {
109
+ // Mark the process before anything can create a session. This runner builds its
110
+ // session with its own makePermissionGate (the in-chat approver below), but the
111
+ // shipped TUI gate extension is still auto-discovered from the shared agent dir —
112
+ // and with no UI bound here its local asker fails CLOSED, denying every gated tool
113
+ // before our approver is ever consulted. See config/inlineMoat.ts.
114
+ const { markInlineMoat } = await import("../config/inlineMoat.ts");
115
+ markInlineMoat();
109
116
  const { readFileSync, appendFileSync } = await import("node:fs");
110
117
  const { join } = await import("node:path");
111
118
  const {
@@ -131,6 +138,8 @@ async function main() {
131
138
  const { DiscordAdapter } = await import("./discord.ts");
132
139
  const { WhatsAppAdapter } = await import("./whatsapp.ts");
133
140
  const { writeChannelsStatus, HEARTBEAT_MS } = await import("./status.ts");
141
+ const { startableFrom } = await import("./platforms.ts");
142
+ const { buzzRedactionSecrets } = await import("../nostr/keys.ts");
134
143
  type ChannelAdapter = import("./types.ts").ChannelAdapter;
135
144
 
136
145
  // ── config ──────────────────────────────────────────────────────────────────
@@ -149,7 +158,10 @@ async function main() {
149
158
  : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
150
159
  const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
151
160
  const cwd: string = ch.cwd ?? process.cwd();
152
- const secrets = collectSecrets(cfg.providers);
161
+ // Provider API keys, plus this machine's Nostr secret if one has been minted.
162
+ // The agent can READ its own key file, so without this it could quote its own
163
+ // permanent identity into a public channel. Non-minting: absent → nothing added.
164
+ const secrets = [...collectSecrets(cfg.providers), ...buzzRedactionSecrets()];
153
165
  const redact = (t: string) => redactText(t, secrets);
154
166
 
155
167
  // Append-only security audit log — every prompt, approval request/decision, and
@@ -279,7 +291,7 @@ async function main() {
279
291
  sweep.unref?.();
280
292
 
281
293
  // ── build a bridge per configured platform ───────────────────────────────────
282
- const bridges: { stop(): void }[] = [];
294
+ const bridges: { stop(): Promise<void> }[] = [];
283
295
  // Platforms with a live bridge — written to the heartbeat file so the app's
284
296
  // channels manager (running on the harbor's relay, a separate process) can show
285
297
  // a live/offline badge without talking to this process.
@@ -343,28 +355,30 @@ async function main() {
343
355
  );
344
356
  }
345
357
 
346
- if (ch.telegram?.botToken) {
358
+ // Which credentials each platform needs to start is declared once, in
359
+ // channels/platforms.ts, so this list and the app's config validator can't drift.
360
+ if (startableFrom("telegram", ch.telegram)) {
347
361
  await startChannel(
348
362
  "telegram",
349
363
  new TelegramAdapter({ botToken: ch.telegram.botToken, onLog: log }),
350
364
  ch.telegram,
351
365
  );
352
366
  }
353
- if (ch.slack?.appToken && ch.slack?.botToken) {
367
+ if (startableFrom("slack", ch.slack)) {
354
368
  await startChannel(
355
369
  "slack",
356
370
  new SlackAdapter({ appToken: ch.slack.appToken, botToken: ch.slack.botToken, onLog: log }),
357
371
  ch.slack,
358
372
  );
359
373
  }
360
- if (ch.discord?.botToken) {
374
+ if (startableFrom("discord", ch.discord)) {
361
375
  await startChannel(
362
376
  "discord",
363
377
  new DiscordAdapter({ botToken: ch.discord.botToken, intents: ch.discord.intents, onLog: log }),
364
378
  ch.discord,
365
379
  );
366
380
  }
367
- if (ch.whatsapp?.phoneNumberId && ch.whatsapp?.accessToken && ch.whatsapp?.verifyToken) {
381
+ if (startableFrom("whatsapp", ch.whatsapp)) {
368
382
  await startChannel(
369
383
  "whatsapp",
370
384
  new WhatsAppAdapter({
@@ -397,7 +411,10 @@ async function main() {
397
411
  clearInterval(sweep);
398
412
  clearInterval(heartbeat);
399
413
  writeChannelsStatus([]); // clear presence immediately, don't wait for staleness
400
- for (const b of bridges) b.stop();
414
+ // Fire-and-forget: process exit releases every socket and port anyway, so there's
415
+ // nothing to wait for here. Awaiting matters for a TARGETED restart (one platform
416
+ // rebinding its port while the process lives on), not for shutdown.
417
+ for (const b of bridges) void b.stop();
401
418
  process.exit(0);
402
419
  };
403
420
  process.on("SIGINT", shutdown);
@@ -1,11 +1,29 @@
1
1
  // Messaging-channel plumbing — the inbound/conversational counterpart to the relay.
2
2
  //
3
3
  // The relay (src/remote/*) lets the Privateer app drive this terminal. A messaging
4
- // channel (Telegram/Slack/Discord/WhatsApp) is the SAME idea with a different
5
- // transport: a user's message becomes a prompt, the agent's reply goes back to the
6
- // channel. `ChannelAdapter` is the one platform-specific seam; everything above it
7
- // (allowlist, per-chat serialization, redaction, chunking) lives in MessagingBridge
8
- // and is shared across every platform.
4
+ // channel (Telegram/Slack/Discord/Buzz) is the SAME idea with a different transport:
5
+ // a user's message becomes a prompt, the agent's reply goes back to the channel.
6
+ // `ChannelAdapter` is the one platform-specific seam; everything above it (allowlist,
7
+ // mention gating, per-chat serialization, redaction, chunking) lives in
8
+ // MessagingBridge and is shared across every platform.
9
+ //
10
+ // EVERY field beyond the original four is OPTIONAL, and that is a load-bearing
11
+ // design choice rather than politeness: an adapter that cannot thread, cannot see
12
+ // mentions, or carries no message ids simply omits them, and the bridge reads
13
+ // "absent" as "this platform doesn't support it". That is what lets a rich platform
14
+ // (Buzz: message ids, NIP-10 threads, relay-indexed mentions, Blossom attachments)
15
+ // share one contract with a plain one, with no per-adapter branching in the bridge.
16
+
17
+ /** A file or image referenced by an inbound message. */
18
+ export interface Attachment {
19
+ /** The platform's content id — Buzz: the Blossom sha256. */
20
+ id?: string;
21
+ /** Fetchable by this machine. May require adapter-supplied auth. */
22
+ url?: string;
23
+ mediaType?: string;
24
+ name?: string;
25
+ bytes?: number;
26
+ }
9
27
 
10
28
  // A normalized inbound message from any platform. `chatId` scopes the conversation
11
29
  // (so each thread keeps its own agent session); `userId` is who sent it (allowlist
@@ -16,21 +34,74 @@ export interface InboundMessage {
16
34
  userId: string;
17
35
  userName?: string;
18
36
  text: string;
37
+
38
+ /** This message's own platform id — Buzz: the nostr event id. Needed to reply
39
+ * in-thread or react to it. */
40
+ messageId?: string;
41
+ /** The message this one directly answers — Buzz: NIP-10 "e" tag marked "reply". */
42
+ replyToId?: string;
43
+ /** The root of the thread this belongs to — Buzz: "e" marked "root". Equal to
44
+ * `replyToId` at depth one. */
45
+ threadRootId?: string;
46
+ /** Everyone this message @-mentions, in platform-native id form. */
47
+ mentions?: string[];
48
+ /** Does it mention THIS agent? Only the adapter knows its own identity on the
49
+ * platform, so the adapter reports the FACT — the bridge owns the POLICY of what
50
+ * to do about it (see `mentionGate`). */
51
+ mentionsMe?: boolean;
52
+ /** True when this is a 1:1 conversation rather than a shared room — Buzz: a
53
+ * channel of type "Dm". A DM is inherently addressed to the agent, so the
54
+ * mention gate can let it through without an explicit @. */
55
+ isDirect?: boolean;
56
+ attachments?: Attachment[];
57
+ }
58
+
59
+ /** How an outbound message relates to the conversation. An adapter that can't
60
+ * thread ignores this entirely — passing it is always safe. */
61
+ export interface SendOptions {
62
+ replyTo?: string;
63
+ threadRoot?: string;
64
+ }
65
+
66
+ export interface OutboundMedia {
67
+ bytes: Uint8Array;
68
+ mediaType: string;
69
+ name?: string;
70
+ caption?: string;
19
71
  }
20
72
 
21
73
  // The per-platform transport. Implementations own the connection (long-poll,
22
- // gateway socket, or inbound webhook) and the wire format; they surface normalized
23
- // messages and accept plain text back. Keep them DUMB: no allowlist, no redaction,
24
- // no chunking — the bridge does all of that so it's written once and tested once.
74
+ // gateway socket, relay websocket, or inbound webhook) and the wire format; they
75
+ // surface normalized messages and accept plain text back. Keep them DUMB: no
76
+ // allowlist, no redaction, no chunking, no gating — the bridge does all of that so
77
+ // it's written once and tested once.
25
78
  export interface ChannelAdapter {
26
79
  readonly name: string;
80
+ /** This agent's own id on the platform — Buzz: its pubkey hex. Used to recognize
81
+ * self-authored events and to resolve `mentionsMe`. Absent when the platform has
82
+ * no stable identity for the bot. */
83
+ readonly selfId?: string;
84
+ /** Per-message ceiling in BYTES of UTF-8 content. The bridge chunks to this;
85
+ * absent falls back to a conservative shared default. Before this existed the
86
+ * bridge hardcoded Discord's limit for every platform. */
87
+ readonly maxMessageBytes?: number;
88
+
27
89
  // Begin receiving. Call `onMessage` for every inbound user message.
28
90
  start(onMessage: (m: InboundMessage) => void): Promise<void>;
29
- // Send a reply to a conversation. The bridge guarantees `text` is already
30
- // redacted and within the platform's per-message length cap.
31
- sendText(chatId: string, text: string): Promise<void>;
91
+ /** Send a reply. The bridge guarantees `text` is already redacted and within
92
+ * `maxMessageBytes`. Returns the sent message's platform id when the adapter
93
+ * knows it, so the bridge can chain later chunks beneath it — returning nothing
94
+ * is fine and simply means later chunks hang off the same parent. */
95
+ sendText(chatId: string, text: string, opts?: SendOptions): Promise<string | void>;
32
96
  // Optional "the agent is working" affordance (typing indicator). Best-effort.
33
97
  sendTyping?(chatId: string): void;
34
- // Stop receiving and release the connection.
35
- stop(): void;
98
+ /** React to a message Buzz: NIP-25 kind 7. Best-effort; never awaited on a
99
+ * path that must not fail. */
100
+ sendReaction?(chatId: string, messageId: string, emoji: string): Promise<void>;
101
+ sendMedia?(chatId: string, media: OutboundMedia, opts?: SendOptions): Promise<void>;
102
+ // Stop receiving and release the connection. May be async: an adapter that owns a
103
+ // LISTENING SOCKET (whatsapp) has to await the port actually being free, or the
104
+ // next start() races its own teardown and fails with EADDRINUSE. Callers must
105
+ // await this before restarting the same platform.
106
+ stop(): void | Promise<void>;
36
107
  }