@sigrun/ext-gateway 0.0.0-stage → 0.1.2
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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +112 -0
- package/dist/interactions/chat.d.ts +28 -0
- package/dist/interactions/chat.js +199 -0
- package/dist/interactions/prompts.d.ts +68 -0
- package/dist/interactions/prompts.js +246 -0
- package/dist/interactions/receipts.d.ts +7 -0
- package/dist/interactions/receipts.js +69 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +64 -0
- package/dist/outbound/delivery.d.ts +131 -0
- package/dist/outbound/delivery.js +404 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +81 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +18 -0
- package/dist/session-meta.js +39 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +65 -0
- package/dist/state/identities.js +135 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +176 -0
- package/dist/state/ledger.js +256 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- 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
|
+
}
|