@sigrun/ext-gateway 0.0.0-stage → 0.1.1

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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +80 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +8 -0
  65. package/dist/session-meta.js +21 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +61 -0
  69. package/dist/state/identities.js +131 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +152 -0
  73. package/dist/state/ledger.js +207 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,600 @@
1
+ /**
2
+ * The inbound pipeline (spec §8.4): one `receive` runs the ordered
3
+ * steps. Task 3 landed steps 2 (dedupe), 3 (self/bot drops), 6 (album
4
+ * merge) and 10 (submit) plus the M12 submit-error outcomes; Task 4
5
+ * fills step 1 (the claimed gate), step 4 (self-link) and step 5 (the
6
+ * DM access policies) INLINE over the shared {@link AccessState} — the
7
+ * access stores are engine-wide (one pairing queue, one link-code
8
+ * table, one token table, one loop guard), so the engine builds the
9
+ * state and hands it to every channel's pipeline. Steps 7 (commands),
10
+ * 8 (interaction answers) and 9 (attachments) remain INJECTABLE STAGES
11
+ * (`PipelineDeps.stages`) filled by their own tasks — the design keeps
12
+ * the step ORDER in one place while each task lands its logic.
13
+ *
14
+ * The channel's config is read LIVE per event by the CALLER (the
15
+ * engine's `receive` wrapper reads `readConfig(host)` and passes the
16
+ * channel's block in) — every field except `enabled` is per-event.
17
+ *
18
+ * On admission the pipeline records `submissionId → { bindingKey,
19
+ * channel }` in `deps.origins` (the in-memory map the outbound tasks
20
+ * consume — which chat a run came from, so settled answers route).
21
+ *
22
+ * The notice/error replies go through `deps.channel.send` — the
23
+ * adapter-bound transport, host-independent (the loopback fixture's
24
+ * `sent` array records them, which is how the sigrun e2e asserts reply
25
+ * text). A failed notice send is logged, never thrown: the submit
26
+ * error already resolved the event's fate.
27
+ */
28
+ import { join } from "node:path";
29
+ import { buildEnvelope } from "./envelope.js";
30
+ import { AlbumCollector, ALBUM_WINDOW_MS } from "./albums.js";
31
+ import { bindingKey, BindingStore } from "../state/bindings.js";
32
+ import { SeenSet } from "../state/seen.js";
33
+ import { IdentityStore } from "../state/identities.js";
34
+ import { PairingStore } from "../access/pairing.js";
35
+ import { LinkCodes } from "../access/link-codes.js";
36
+ import { LoopGuard } from "../access/loop-guard.js";
37
+ import { ButtonTokens } from "./tokens.js";
38
+ import { normalizeCode } from "../access/codes.js";
39
+ import { redeemLink } from "../commands/link.js";
40
+ import { approvePairing, denyPairing } from "../commands/pair.js";
41
+ import { handleChatCommand } from "../commands/chat.js";
42
+ import { admitGroupEvent, ensureGroupShare } from "./groups.js";
43
+ /** The pinned UnservedModelError notice (spec §8.4 M12, exact copy). */
44
+ const UNSERVED_MODEL_NOTICE = "The model <ref> is not available. Use /model to pick another.";
45
+ /** The pinned group refusal for `/link` (spec §8.2, exact copy). */
46
+ const LINK_GROUP_REFUSAL = "send this in a direct message";
47
+ /** Build a private access state over `stateDir` (the no-`access` shape). */
48
+ function privateAccess(host, stateDir, now, log) {
49
+ return {
50
+ identities: new IdentityStore(host, join(stateDir, "identities.json"), { log }),
51
+ pairing: new PairingStore(join(stateDir, "pairing.json"), { now }),
52
+ linkCodes: new LinkCodes(),
53
+ tokens: new ButtonTokens(),
54
+ loopGuard: new LoopGuard(),
55
+ };
56
+ }
57
+ export function createPipeline(deps) {
58
+ const now = deps.now ?? Date.now;
59
+ // The engine hands ONE store in (every pipeline, the settled fan-out
60
+ // and the delivery workers share it — cross-instance writes would
61
+ // interleave separate JsonFile queues and lose updates); the
62
+ // stand-alone shape constructs its own.
63
+ const bindings = deps.bindings ??
64
+ new BindingStore(join(deps.stateDir, "bindings.json"), { now });
65
+ const seen = new SeenSet(join(deps.stateDir, "seen.json"));
66
+ const access = deps.access ??
67
+ privateAccess(deps.host, deps.stateDir, () => now(), deps.log);
68
+ const albums = new AlbumCollector(now, ALBUM_WINDOW_MS);
69
+ // Per-group close timers (the album window's flush).
70
+ const albumTimers = new Map();
71
+ // The open groups' submission metadata, keyed by the same timer key:
72
+ // a flush (stopIntake) needs everything a receive would have carried.
73
+ const openAlbums = new Map();
74
+ const receivePipeline = async (channelId, event, config) => {
75
+ const key = bindingKey(channelId, event.chat);
76
+ const seenId = `${key}:${event.messageId}`;
77
+ // Step 1 — the claimed gate (§8.2): an unclaimed deployment has
78
+ // no users to act for; every event drops with ONE log line per
79
+ // channel (the state can flip mid-process — the check is per
80
+ // event, not cached at start).
81
+ if ((await deps.host.users.list()).length === 0) {
82
+ deps.log.once(`unclaimed:${channelId}`, "warn", `setup unclaimed — dropping inbound (channel "${channelId}")`);
83
+ return dropped();
84
+ }
85
+ // Step 2 — dedupe (in-flight-aware: a racing redelivery claims
86
+ // false and drops; an engine failure releases for the retry).
87
+ if (!seen.claim(seenId, now())) {
88
+ return dropped();
89
+ }
90
+ try {
91
+ return await admit(channelId, event, key, config);
92
+ }
93
+ catch (error) {
94
+ // An unexpected throw is an ENGINE failure: release the seen id
95
+ // (the platform redelivers) and reject — the adapter does not
96
+ // ack, so the message is retried rather than lost.
97
+ seen.release(seenId);
98
+ throw error;
99
+ }
100
+ };
101
+ /** The pipeline past the dedupe claim. */
102
+ async function admit(channelId, event, key, config) {
103
+ // Step 3 — self and bot drops. An ADMITTED bot rides the loop
104
+ // guard (§8.2: 10 per chat per 60000 ms, then a 300000 ms
105
+ // cooldown) — a chatty integration must never monopolize the run
106
+ // queue or echo itself into a loop.
107
+ if (event.fromSelf === true)
108
+ return dropped();
109
+ if (event.sender.isBot === true) {
110
+ if (!config.allowBots)
111
+ return dropped();
112
+ if (!access.loopGuard.admit(key, now())) {
113
+ deps.log.once(`loop-guard:${key}`, "warn", "bot loop guard tripped — bot messages on this chat drop for 5 minutes");
114
+ return dropped();
115
+ }
116
+ }
117
+ // Step 4 — self-link (`/link <code>`, §8.2): handled BEFORE
118
+ // access — the code IS the authorization, so the sender may be
119
+ // unknown here. Only when the channel's `selfLink` is on; a
120
+ // selfLink-off channel leaves `/link` to the access policy (an
121
+ // allowlist channel's operator chose a closed list).
122
+ if (config.selfLink && isLinkCommand(event.text)) {
123
+ if (event.chat.chatType !== "dm") {
124
+ // The code was exposed to other members (M9): void it, then
125
+ // refuse. The refusal is exact (the plan pins the string).
126
+ access.linkCodes.invalidate(normalizeCode(event.text.replace(/^\/link\s*/, "")));
127
+ await sendNotice(event.chat, LINK_GROUP_REFUSAL, undefined);
128
+ return { outcome: "command" };
129
+ }
130
+ const reply = await redeemLink({
131
+ identities: access.identities,
132
+ linkCodes: access.linkCodes,
133
+ log: deps.log,
134
+ now,
135
+ }, channelId, event);
136
+ await sendNotice(event.chat, reply, undefined);
137
+ return { outcome: "command" };
138
+ }
139
+ // Step 5 — access (§8.2). Groups read their row from the live
140
+ // config per event; removing a row stops admission without
141
+ // deleting the chat's durable binding (Review Focus 3).
142
+ let user;
143
+ let commandUser;
144
+ let owner;
145
+ let group;
146
+ let senderLabel = event.sender.name ?? event.sender.id;
147
+ if (event.chat.chatType !== "dm") {
148
+ const admission = await admitGroupEvent(config, channelId, event, access.identities);
149
+ if (!admission.admit) {
150
+ if (admission.reason === "unlisted" || admission.reason === "failed") {
151
+ deps.log.once(`group-${admission.reason}:${channelId}:${event.chat.chatId}`, "warn", `group message dropped (${admission.reason}, channel "${channelId}")`);
152
+ }
153
+ else if (admission.reason === "unlinked") {
154
+ const day = Math.floor(now() / 86_400_000);
155
+ deps.log.once(`group-unlinked:${channelId}:${event.sender.id}:${day}`, "warn", `group message from unlinked sender dropped (channel "${channelId}")`);
156
+ }
157
+ return dropped();
158
+ }
159
+ user = admission.user;
160
+ // Unlinked anyone senders submit under the owner but never
161
+ // control the conversation or invoke commands (§8.2).
162
+ commandUser = admission.linked ? admission.user : undefined;
163
+ owner = admission.group.owner;
164
+ group = admission.group;
165
+ if (admission.linked) {
166
+ const directoryUser = await deps.host.users.get(admission.user);
167
+ senderLabel = directoryUser?.name ?? admission.user;
168
+ }
169
+ else {
170
+ senderLabel = admission.senderLabel;
171
+ }
172
+ }
173
+ else {
174
+ const resolved = await access.identities.userFor(config, channelId, event.sender.id);
175
+ if (resolved === undefined) {
176
+ return await unknownDm(channelId, event, config);
177
+ }
178
+ user = resolved.user;
179
+ commandUser = user;
180
+ owner = user;
181
+ // An allowlisted identity has no link row to seed the home DM.
182
+ // Record this DM as the channel home when none exists so tools
183
+ // and notices have the same target as linked identities.
184
+ if (access.identities.homesOf(user).every((home) => home.channel !== channelId)) {
185
+ // Keep admission ordered, especially for album siblings: only
186
+ // the first writer installs the home; no async write here.
187
+ // Album siblings can race this first-home write; keep the
188
+ // synchronous check/update ordering and await durability.
189
+ void access.identities.ensureHome(user, channelId, event.chat)
190
+ .catch((error) => {
191
+ deps.log.error("home chat persistence failed", error);
192
+ });
193
+ }
194
+ }
195
+ // The bound conversation, or a fresh one for an unbound chat.
196
+ const binding = bindings.get(key);
197
+ if (binding?.dead) {
198
+ await bindings.clearDead(key);
199
+ }
200
+ let conversationId;
201
+ if (binding === undefined) {
202
+ conversationId = (await deps.host.conversations.create({
203
+ owner,
204
+ source: "gateway",
205
+ name: event.chat.title ??
206
+ `${deps.channel.label} ${group === undefined ? "DM" : "group"}`,
207
+ ...(group?.members !== "anyone"
208
+ ? {}
209
+ : {
210
+ agent: {
211
+ tools: group.tools,
212
+ ...(group.extensions === undefined
213
+ ? {}
214
+ : { extensions: group.extensions }),
215
+ },
216
+ }),
217
+ }));
218
+ await bindings.bind(key, conversationId, event.chat);
219
+ }
220
+ else {
221
+ conversationId = binding.conversationId;
222
+ // An archived conversation unarchives before submit (spec
223
+ // §8.3's my-call: archiving never silently breaks a chat).
224
+ const meta = await deps.host.conversations.meta(conversationId);
225
+ if (meta.archived) {
226
+ await deps.host.conversations.patchMeta(conversationId, {
227
+ archived: false,
228
+ });
229
+ }
230
+ }
231
+ if (group !== undefined && commandUser !== undefined) {
232
+ await ensureGroupShare(deps.host, conversationId, commandUser);
233
+ }
234
+ // Step 7 — commands run INLINE (§8.5), before album merging and
235
+ // the busy submission queue. A first-message command acts on the
236
+ // binding admission just created, not on the earlier snapshot.
237
+ // created, not on the pre-creation `undefined` snapshot.
238
+ const currentBinding = bindings.get(key) ?? binding;
239
+ const command = await handleChatCommand({
240
+ host: deps.host,
241
+ channel: deps.channel,
242
+ bindings,
243
+ identities: access.identities,
244
+ pairing: access.pairing,
245
+ linkCodes: access.linkCodes,
246
+ streams: deps.streams,
247
+ channelOf: (id) => deps.channelOf?.(id) ?? (id === channelId ? deps.channel : undefined),
248
+ log: deps.log,
249
+ now,
250
+ }, {
251
+ channel: channelId,
252
+ event,
253
+ bindingKey: key,
254
+ user: commandUser,
255
+ binding: currentBinding,
256
+ selfLink: config.selfLink,
257
+ ...(group === undefined ? {} : { group }),
258
+ });
259
+ if (command === "command") {
260
+ await seen.commit(`${key}:${event.messageId}`, now());
261
+ return { outcome: "command" };
262
+ }
263
+ // Step 8 — interaction text answers (injected; Task 9 fills).
264
+ const interaction = await deps.stages?.interactionAnswer?.(channelId, event);
265
+ if (interaction !== undefined) {
266
+ await seen.commit(`${key}:${event.messageId}`, now());
267
+ return interaction;
268
+ }
269
+ // Step 6 — the album merge: a group's events become ONE
270
+ // submission when the window closes; a plain event is its own.
271
+ const envelopeText = buildEnvelope(event, {
272
+ group: group !== undefined,
273
+ senderLabel,
274
+ });
275
+ const batch = albums.add(key, event, envelopeText);
276
+ if (batch === undefined) {
277
+ // Collecting: commit seen NOW (the events ARE admitted — a
278
+ // platform redelivery of a held sibling is a no-op). A SIGKILL
279
+ // before the window closes loses this submission by design;
280
+ // graceful stopIntake flushes it, and redelivery dedupes safely.
281
+ // Arm the close timer that submits the merged batch.
282
+ await seen.commit(`${key}:${event.messageId}`, now());
283
+ scheduleAlbumClose(channelId, key, event, user, conversationId, config);
284
+ return { outcome: "admitted" };
285
+ }
286
+ // Step 10 — submit.
287
+ return await submit(channelId, event, key, conversationId, user, batch, config);
288
+ }
289
+ /** Step 5's unknown-DM branch: the channel's `dm` policy (§8.2). */
290
+ async function unknownDm(channelId, event, config) {
291
+ if (config.dm === "disabled") {
292
+ // Silent: no reply, no log (the operator turned DMs off —
293
+ // a log line per stranger would be noise, not signal).
294
+ return dropped();
295
+ }
296
+ if (config.dm === "allowlist") {
297
+ // Drop, logged once per sender per day (the day bucket in the
298
+ // key; a restart re-logs once — the honest per-process bound).
299
+ const day = Math.floor(now() / 86_400_000);
300
+ deps.log.once(`dm-drop:${channelId}:${event.sender.id}:${day}`, "warn", `dm from unknown sender dropped (channel "${channelId}")`);
301
+ return dropped();
302
+ }
303
+ // `pairing`: one request, one reply carrying the code, admin
304
+ // notices — and the message itself is NEVER submitted, then or
305
+ // after approval (§8.2).
306
+ const result = access.pairing.request(channelId, { id: event.sender.id, name: event.sender.name }, event.chat, now());
307
+ if ("limited" in result) {
308
+ // No reply on a limit (§8.2): a reply would be an oracle for a
309
+ // probing sender. The drop is logged once per sender.
310
+ deps.log.once(`pairing-limited:${channelId}:${event.sender.id}`, "info", `pairing request over limit (${result.limited}, channel "${channelId}")`);
311
+ return dropped();
312
+ }
313
+ await sendNotice(event.chat, `Your pairing code is ${result.code}. An admin will approve it — ` +
314
+ "until then your messages are not processed.", undefined);
315
+ await notifyAdmins(channelId, event, result.code, config);
316
+ return dropped();
317
+ }
318
+ /** The pairing notice to every effective admin's home DM (§8.2). */
319
+ async function notifyAdmins(channelId, event, code, config) {
320
+ void config;
321
+ const users = await deps.host.users.list();
322
+ const admins = users.filter((row) => row.admin === true || row.groupAdmin === true);
323
+ if (admins.length === 0)
324
+ return;
325
+ const who = event.sender.name ?? event.sender.id;
326
+ const text = `Pairing request from ${who} on ${deps.channel.label}.\n` +
327
+ `/pair approve ${code} <user>\n` +
328
+ `/pair deny ${code}`;
329
+ // Buttons when they fit: one "Approve as <user>" per DIRECTORY
330
+ // user plus Deny, within `buttons.max` (the max-1 bound leaves
331
+ // room for the Deny row entry). Too many users → text only.
332
+ const caps = deps.channel.capabilities.buttons;
333
+ const rows = caps === undefined || users.length > caps.max - 1
334
+ ? undefined
335
+ : [
336
+ [
337
+ ...users.map((row) => ({
338
+ label: `Approve as ${row.user}`,
339
+ data: access.tokens.issue({
340
+ kind: "pair",
341
+ code,
342
+ decision: "approve",
343
+ user: row.user,
344
+ }),
345
+ style: "primary",
346
+ })),
347
+ {
348
+ label: "Deny",
349
+ data: access.tokens.issue({
350
+ kind: "pair",
351
+ code,
352
+ decision: "deny",
353
+ }),
354
+ style: "danger",
355
+ },
356
+ ],
357
+ ];
358
+ for (const admin of admins) {
359
+ // §8.8: an admin's home chats are always DMs; the notice rides
360
+ // THIS channel's adapter, so only this channel's homes get it.
361
+ for (const home of access.identities.homesOf(admin.user)) {
362
+ if (home.channel !== channelId)
363
+ continue;
364
+ await sendNotice(home.chat, text, rows);
365
+ }
366
+ }
367
+ }
368
+ /** Step 10: the submit + the M12 error outcomes. */
369
+ async function submit(channelId, event, key, conversationId, user, batch, config) {
370
+ const text = batch.texts.join("\n");
371
+ const media = deps.stages?.attachments === undefined
372
+ ? { parts: [], notes: [] }
373
+ : await deps.stages.attachments({ ...event, attachments: batch.attachments });
374
+ // A media-only event submits its notes, not an empty text block.
375
+ const textParts = [
376
+ ...(text === "" ? [] : [{ type: "text", text }]),
377
+ ...media.notes.map((note) => ({ type: "text", text: note })),
378
+ ];
379
+ const parts = [...media.parts, ...textParts];
380
+ // Host submission rejects an empty input array; preserve an empty
381
+ // attachment-less event as an empty text part.
382
+ if (parts.length === 0)
383
+ parts.push({ type: "text", text });
384
+ const opts = {
385
+ user,
386
+ requestId: `${key}:${event.messageId}`,
387
+ whenBusy: config.busy,
388
+ };
389
+ const origin = {
390
+ bindingKey: key,
391
+ channel: channelId,
392
+ chat: event.chat,
393
+ };
394
+ let ownsStream = false;
395
+ if (deps.streams !== undefined) {
396
+ try {
397
+ // Open BEFORE submit: a fast provider may publish its first (or
398
+ // only) live frame before submit's promise resolves.
399
+ ownsStream = await deps.streams.open(conversationId, origin);
400
+ }
401
+ catch (error) {
402
+ // Admission must still proceed when only the preview cannot open.
403
+ deps.log.error("stream open failed", error);
404
+ }
405
+ }
406
+ try {
407
+ const record = await deps.host.conversations.submit(conversationId, parts, opts);
408
+ await seen.commit(`${key}:${event.messageId}`, now());
409
+ deps.origins.set(record.id, origin);
410
+ return { outcome: "admitted" };
411
+ }
412
+ catch (error) {
413
+ if (ownsStream)
414
+ deps.streams?.abandon(conversationId);
415
+ if (error instanceof Error &&
416
+ error.name === "UnservedModelError") {
417
+ // The pinned notice with the ref interpolated (M12). Named
418
+ // by `name`, never `instanceof` across the types-only
419
+ // contract (the error class ships in sigrun's runtime, not
420
+ // the extension types).
421
+ const ref = unservedModelRef(error);
422
+ // Commit seen BEFORE the notice: the idempotency record
423
+ // must be durable before the fallible network write — a
424
+ // commit failure after a sent notice would make the
425
+ // platform redeliver into a duplicate submit + duplicate
426
+ // notice.
427
+ await seen.commit(`${key}:${event.messageId}`, now());
428
+ await sendNotice(event.chat, UNSERVED_MODEL_NOTICE.replace("<ref>", ref), undefined);
429
+ return dropped();
430
+ }
431
+ if (error instanceof Error &&
432
+ error.name === "ConversationOpError") {
433
+ // A short notice + the error code logged (M12).
434
+ const code = conversationOpCode(error);
435
+ deps.log.error(`submit failed (conversation ${String(conversationId)}, code "${code}")`, error);
436
+ // Commit seen BEFORE the notice (the same ordering as the
437
+ // UnservedModelError branch: idempotency first, network second).
438
+ await seen.commit(`${key}:${event.messageId}`, now());
439
+ await sendNotice(event.chat, "Could not deliver that message. Try again in a moment.", undefined);
440
+ return dropped();
441
+ }
442
+ throw error;
443
+ }
444
+ }
445
+ /** Send one notice reply through the adapter (errors logged, never thrown). */
446
+ async function sendNotice(chat, text, buttons) {
447
+ const message = {
448
+ chat,
449
+ text,
450
+ ...(buttons === undefined ? {} : { buttons }),
451
+ };
452
+ try {
453
+ await deps.channel.send(message);
454
+ }
455
+ catch (error) {
456
+ deps.log.error("notice send failed", error);
457
+ }
458
+ }
459
+ /** Arm the album-close timer for a collecting group (one per group). */
460
+ function scheduleAlbumClose(channelId, key, event, user, conversationId, config) {
461
+ if (event.groupId === undefined)
462
+ return;
463
+ const timerKey = `${key}:${event.groupId}`;
464
+ if (albumTimers.has(timerKey))
465
+ return;
466
+ openAlbums.set(timerKey, {
467
+ channelId,
468
+ key,
469
+ groupId: event.groupId,
470
+ event,
471
+ user,
472
+ conversationId,
473
+ config,
474
+ });
475
+ const timer = setTimeout(() => {
476
+ albumTimers.delete(timerKey);
477
+ openAlbums.delete(timerKey);
478
+ const batch = albums.close(key, event.groupId ?? "");
479
+ if (batch.texts.length === 0)
480
+ return;
481
+ void submit(channelId, event, key, conversationId, user, batch, config)
482
+ .catch((error) => {
483
+ // The timer path has no receive to reject: an unexpected
484
+ // throw logs loud (the album's events are committed seen;
485
+ // the M12 classes already resolved their own outcome).
486
+ deps.log.error("album submit failed", error);
487
+ });
488
+ }, ALBUM_WINDOW_MS);
489
+ albumTimers.set(timerKey, timer);
490
+ }
491
+ /**
492
+ * Flush every open album window NOW and submit its merged batch —
493
+ * the engine's `stopIntake` calls this so a shutdown inside the
494
+ * 1500 ms window never loses a collected album (its events are
495
+ * already committed seen; without this flush the platform's
496
+ * redelivery would be a dedupe no-op, permanently dropping them).
497
+ *
498
+ * Every submission settles before this resolves; an unexpected
499
+ * throw on one album is logged (the flush still drains the rest —
500
+ * one failed album must not strand the others at shutdown).
501
+ */
502
+ async function flushAlbums() {
503
+ for (const timer of albumTimers.values())
504
+ clearTimeout(timer);
505
+ albumTimers.clear();
506
+ const pending = [...openAlbums.values()];
507
+ openAlbums.clear();
508
+ await Promise.all(pending.map(async (album) => {
509
+ const batch = albums.close(album.key, album.groupId);
510
+ if (batch.texts.length === 0)
511
+ return;
512
+ try {
513
+ await submit(album.channelId, album.event, album.key, album.conversationId, album.user, batch, album.config);
514
+ }
515
+ catch (error) {
516
+ deps.log.error("album flush submit failed", error);
517
+ }
518
+ }));
519
+ }
520
+ // The tap handler (§8.7 button tokens): a pair decision needs an
521
+ // EFFECTIVE admin tapper (C8) — the tapper's platform identity maps
522
+ // through the same allowlist/links, then the directory record's
523
+ // admin flag or group grant decides. Everything else resolves
524
+ // quietly: a replayed/foreign token, an unknown sender, a plain
525
+ // user. The requester's "linked" notice rides `approvePairing`.
526
+ async function pipelineAction(channelId, event, config) {
527
+ const action = access.tokens.resolve(event.data);
528
+ if (action === undefined)
529
+ return;
530
+ const tapper = await access.identities.userFor(config, channelId, event.sender.id);
531
+ if (action.kind === "interaction") {
532
+ if (deps.interactionAnswer === undefined)
533
+ return;
534
+ if (action.field === undefined) {
535
+ deps.log.error("interaction token has no field; refusing to invent one");
536
+ return;
537
+ }
538
+ const reply = await deps.interactionAnswer({
539
+ conversationId: action.conversationId,
540
+ callId: action.callId,
541
+ values: { [action.field]: action.optionId },
542
+ user: tapper?.user,
543
+ });
544
+ await sendNotice(event.chat, reply, undefined);
545
+ return;
546
+ }
547
+ if (tapper === undefined) {
548
+ deps.log.once(`tap-unknown:${channelId}:${event.sender.id}`, "warn", "pairing button tap from an unknown sender ignored");
549
+ return;
550
+ }
551
+ const record = await deps.host.users.get(tapper.user);
552
+ if (record === undefined || (record.admin !== true && record.groupAdmin !== true)) {
553
+ deps.log.once(`tap-not-admin:${channelId}:${tapper.user}`, "warn", `pairing button tap by non-admin "${tapper.user}" ignored`);
554
+ return;
555
+ }
556
+ const pairDeps = {
557
+ host: deps.host,
558
+ identities: access.identities,
559
+ pairing: access.pairing,
560
+ channelOf: (id) => id === channelId ? deps.channel : undefined,
561
+ log: deps.log,
562
+ now,
563
+ };
564
+ if (action.decision === "deny") {
565
+ await denyPairing(pairDeps, action.code);
566
+ return;
567
+ }
568
+ if (action.user === undefined)
569
+ return;
570
+ await approvePairing(pairDeps, action.code, action.user);
571
+ }
572
+ // The flush + tap handles ride the returned receive function
573
+ // (properties, not a separate return shape — the engine holds the
574
+ // same callable it wires into every adapter's context).
575
+ receivePipeline.flushAlbums = flushAlbums;
576
+ receivePipeline.action = pipelineAction;
577
+ return receivePipeline;
578
+ }
579
+ /** Is `text` a `/link` invocation (with or without an argument)? */
580
+ function isLinkCommand(text) {
581
+ return text === "/link" || text.startsWith("/link ");
582
+ }
583
+ /** Extract the `provider/modelId` ref from an UnservedModelError-like. */
584
+ function unservedModelRef(error) {
585
+ const ref = error.ref;
586
+ if (ref?.provider !== undefined && ref.modelId !== undefined) {
587
+ return `${ref.provider}/${ref.modelId}`;
588
+ }
589
+ return "the configured model";
590
+ }
591
+ /** Extract the error code from a ConversationOpError-like. */
592
+ function conversationOpCode(error) {
593
+ const body = error
594
+ .body;
595
+ return body?.error ?? error.message;
596
+ }
597
+ /** The dropped outcome. */
598
+ function dropped() {
599
+ return { outcome: "dropped" };
600
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Button tokens (spec §8.7 §12 "opaque transport-private button
3
+ * tokens"): the opaque `data` strings the engine puts on chat buttons
4
+ * and resolves when a tap arrives. Opaque BY DESIGN — a token carries
5
+ * no readable decision, so a screenshot or log line leaks nothing an
6
+ * attacker could replay through a different chat.
7
+ *
8
+ * Single-use on resolve: a tap consumes its token, so a replayed tap
9
+ * (a double delivery, a copied message) is a no-op. In memory — a
10
+ * restart voids outstanding tokens, which only ever adorn live chat
11
+ * messages.
12
+ *
13
+ * Task 9 (chat interactions) mints the `kind: "interaction"` tokens
14
+ * through this same class: ONE table per engine, every button routes
15
+ * through it.
16
+ */
17
+ /** What a button does when tapped. */
18
+ export type TokenAction = {
19
+ readonly kind: "pair";
20
+ readonly code: string;
21
+ readonly decision: "approve" | "deny";
22
+ readonly user?: string;
23
+ } | {
24
+ readonly kind: "interaction";
25
+ readonly conversationId: number;
26
+ readonly callId: string;
27
+ /** The root-form choice field (U6 records are not decision-specific). */
28
+ readonly field?: string;
29
+ readonly optionId: string;
30
+ };
31
+ /** The button-token table, one instance per engine. */
32
+ export declare class ButtonTokens {
33
+ private readonly live;
34
+ /** Mint a token for `action` (the `data` a button carries). */
35
+ issue(action: TokenAction): string;
36
+ /** Resolve a tap: the action, consumed (undefined on replay). */
37
+ resolve(token: string): TokenAction | undefined;
38
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Button tokens (spec §8.7 §12 "opaque transport-private button
3
+ * tokens"): the opaque `data` strings the engine puts on chat buttons
4
+ * and resolves when a tap arrives. Opaque BY DESIGN — a token carries
5
+ * no readable decision, so a screenshot or log line leaks nothing an
6
+ * attacker could replay through a different chat.
7
+ *
8
+ * Single-use on resolve: a tap consumes its token, so a replayed tap
9
+ * (a double delivery, a copied message) is a no-op. In memory — a
10
+ * restart voids outstanding tokens, which only ever adorn live chat
11
+ * messages.
12
+ *
13
+ * Task 9 (chat interactions) mints the `kind: "interaction"` tokens
14
+ * through this same class: ONE table per engine, every button routes
15
+ * through it.
16
+ */
17
+ import { randomInt } from "node:crypto";
18
+ /** The token alphabet (RFC 4648 base32 — the Crockford-adjacent set). */
19
+ const TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
20
+ /** The pinned token body length (16 chars, `t:`-prefixed). */
21
+ const TOKEN_LENGTH = 16;
22
+ /** The button-token table, one instance per engine. */
23
+ export class ButtonTokens {
24
+ live = new Map();
25
+ /** Mint a token for `action` (the `data` a button carries). */
26
+ issue(action) {
27
+ let body = "";
28
+ for (let i = 0; i < TOKEN_LENGTH; i++) {
29
+ body += TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)];
30
+ }
31
+ const token = `t:${body}`;
32
+ this.live.set(token, action);
33
+ return token;
34
+ }
35
+ /** Resolve a tap: the action, consumed (undefined on replay). */
36
+ resolve(token) {
37
+ const action = this.live.get(token);
38
+ if (action !== undefined)
39
+ this.live.delete(token);
40
+ return action;
41
+ }
42
+ }