@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -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 +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -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 +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -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 +81 -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 +18 -0
  65. package/dist/session-meta.js +39 -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 +65 -0
  69. package/dist/state/identities.js +135 -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 +176 -0
  73. package/dist/state/ledger.js +256 -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,86 @@
1
+ /**
2
+ * sigrun-ext-gateway: the gateway engine factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract: the default export is a factory
5
+ * DECLARING the host parameter (arity 1 — the host rule; a length-0
6
+ * factory with a defaulted first parameter would consume the host as
7
+ * that dependency), returning `{ extension, gateway, routes,
8
+ * commands }`.
9
+ *
10
+ * The extension is a bare `defineExtension({ name: "gateway" })`:
11
+ * the prompt section (§8.10) and the `send_message`/`list_chats` tools
12
+ * (§8.9) land with the tasks that own them. The `gateway` engine is
13
+ * built over the host at factory time (cheap closure wiring — no
14
+ * timers, no I/O; the pi lifecycle rule: the factory registers
15
+ * handlers only, `start` runs after the server listens), the admin
16
+ * status route (Task 2, §8.12) renders the engine's status read, and
17
+ * the `/pair` + `/link` slash commands (Task 4, §8.2) close over the
18
+ * SAME engine-wide access state the pipelines use — a webui approval
19
+ * and a chat-button tap drive the same pairing queue, and an issued
20
+ * link code redeems in the matching channel's pipeline.
21
+ */
22
+ import type { SigrunHost, SigrunExtensionResult } from "@sigrun/sigrun/extension";
23
+ /**
24
+ * The sigrun extension-package factory: builds the engine over the host.
25
+ * One factory call, one engine (the one-engine rule holds at the loader:
26
+ * a second declarer quarantines).
27
+ */
28
+ export default function createGateway(host: SigrunHost): SigrunExtensionResult;
29
+ export { createEngine } from "./engine.js";
30
+ export { collectAttachments, TOO_LARGE_NOTE, VOICE_NOTE, ATTACHMENT_FAILED_NOTE } from "./inbound/media.js";
31
+ export type { MediaDeps, CollectedAttachments } from "./inbound/media.js";
32
+ export { createGatewayTools, RECEIPT_WAIT_MS } from "./tools.js";
33
+ export type { ToolDeps } from "./tools.js";
34
+ export { createChannelSection } from "./section.js";
35
+ export type { ChannelSectionDeps } from "./section.js";
36
+ export type { EngineDeps, EngineInternals } from "./engine.js";
37
+ export { splitMarkdown, stripMarkdown } from "./outbound/markdown.js";
38
+ export { renderChunks } from "./outbound/render.js";
39
+ export type { SerializedOutbound, LedgerEntry, LedgerInput, LedgerKind, LedgerState } from "./state/ledger.js";
40
+ export { Ledger, PRUNE_AFTER_MS } from "./state/ledger.js";
41
+ export { DeliveryWorker, RESENT_MARKER } from "./outbound/delivery.js";
42
+ export type { DeliveryDeps } from "./outbound/delivery.js";
43
+ export { onRunSettled, NO_TEXT_REPLY, STOPPED } from "./outbound/settled.js";
44
+ export { parseTarget, resolveTargets } from "./jobs.js";
45
+ export type { DeliveryTarget, RejectedTarget, ResolvedTarget, ResolveTargetsResult, TargetDeps, } from "./jobs.js";
46
+ export { createNotifier } from "./notify.js";
47
+ export type { NotifyDeps } from "./notify.js";
48
+ export type { SettledDeps, Streams } from "./outbound/settled.js";
49
+ export { StreamRegistry } from "./outbound/stream.js";
50
+ export type { StreamOrigin, StreamRegistryDeps } from "./outbound/stream.js";
51
+ export { Supervisor } from "./supervisor.js";
52
+ export type { ChannelState, ChannelStatus, StatusSink, SupervisorDeps, } from "./supervisor.js";
53
+ export { createStatusRoute } from "./status-route.js";
54
+ export type { GatewayStatus, StatusPairingRow, StatusRead, } from "./status-route.js";
55
+ export { parseGatewayConfig, readConfig, DEFAULT_MEDIA_MAX_BYTES, } from "./config.js";
56
+ export type { ChannelConfig, DmPolicy, GatewayConfig, GroupConfig, } from "./config.js";
57
+ export { JsonFile } from "./state/json-file.js";
58
+ export { bindingKey, BindingStore } from "./state/bindings.js";
59
+ export type { Binding } from "./state/bindings.js";
60
+ export { SeenSet } from "./state/seen.js";
61
+ export { IdentityStore } from "./state/identities.js";
62
+ export type { LinkResult, LinkVia, ResolvedUser } from "./state/identities.js";
63
+ export { buildEnvelope } from "./inbound/envelope.js";
64
+ export { createPipeline } from "./inbound/pipeline.js";
65
+ export type { AccessState, PipelineDeps, PipelineOutcome, PipelineStreams, PipelineStages, Receive, SubmissionOrigin, } from "./inbound/pipeline.js";
66
+ export { AlbumCollector, ALBUM_WINDOW_MS } from "./inbound/albums.js";
67
+ export { ButtonTokens } from "./inbound/tokens.js";
68
+ export type { TokenAction } from "./inbound/tokens.js";
69
+ export { CODE_ALPHABET, newCode, normalizeCode } from "./access/codes.js";
70
+ export { PairingStore, PAIRING_TTL_MS } from "./access/pairing.js";
71
+ export type { PairingRequest, RequestResult } from "./access/pairing.js";
72
+ export { LinkCodes } from "./access/link-codes.js";
73
+ export { LoopGuard } from "./access/loop-guard.js";
74
+ export { runPair } from "./commands/pair.js";
75
+ export type { PairCaller, PairDeps } from "./commands/pair.js";
76
+ export { createLinkCommand, redeemLink } from "./commands/link.js";
77
+ export { GATEWAY_COMMANDS, chatCommandMenu, handleChatCommand, } from "./commands/chat.js";
78
+ export type { ChatCommandContext, ChatCommandDeps, GatewayCommand, } from "./commands/chat.js";
79
+ export type { LinkCommand, LinkCommandDeps, LinkDeps, } from "./commands/link.js";
80
+ export { createLog, hashId } from "./log.js";
81
+ export { planInteraction, renderInteraction } from "./interactions/render.js";
82
+ export type { ChatAnswerPlan, RenderInteractionOptions } from "./interactions/render.js";
83
+ export { PromptStore } from "./interactions/prompts.js";
84
+ export { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
85
+ export { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
86
+ export type { EngineLog } from "./log.js";
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * sigrun-ext-gateway: the gateway engine factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract: the default export is a factory
5
+ * DECLARING the host parameter (arity 1 — the host rule; a length-0
6
+ * factory with a defaulted first parameter would consume the host as
7
+ * that dependency), returning `{ extension, gateway, routes,
8
+ * commands }`.
9
+ *
10
+ * The extension is a bare `defineExtension({ name: "gateway" })`:
11
+ * the prompt section (§8.10) and the `send_message`/`list_chats` tools
12
+ * (§8.9) land with the tasks that own them. The `gateway` engine is
13
+ * built over the host at factory time (cheap closure wiring — no
14
+ * timers, no I/O; the pi lifecycle rule: the factory registers
15
+ * handlers only, `start` runs after the server listens), the admin
16
+ * status route (Task 2, §8.12) renders the engine's status read, and
17
+ * the `/pair` + `/link` slash commands (Task 4, §8.2) close over the
18
+ * SAME engine-wide access state the pipelines use — a webui approval
19
+ * and a chat-button tap drive the same pairing queue, and an issued
20
+ * link code redeems in the matching channel's pipeline.
21
+ */
22
+ import { defineExtension } from "@earendil-works/pi-durable";
23
+ import { createEngine } from "./engine.js";
24
+ import { createStatusRoute } from "./status-route.js";
25
+ import { runPair } from "./commands/pair.js";
26
+ import { createLinkCommand } from "./commands/link.js";
27
+ import { readConfig } from "./config.js";
28
+ import { createGatewayTools } from "./tools.js";
29
+ import { createChannelSection } from "./section.js";
30
+ /**
31
+ * The sigrun extension-package factory: builds the engine over the host.
32
+ * One factory call, one engine (the one-engine rule holds at the loader:
33
+ * a second declarer quarantines).
34
+ */
35
+ export default function createGateway(host) {
36
+ if (host.accounts === undefined) {
37
+ throw new Error("gateway requires sigrun core with host.accounts (multi-user M3a)");
38
+ }
39
+ const engine = createEngine(host);
40
+ // `/pair` (§8.2): the admin surface over the shared pairing queue.
41
+ // The invoke route hands the caller's principal (user + EFFECTIVE
42
+ // admin — the stored flag OR the persisted group grant, C8); an
43
+ // unnamed loopback caller gets the pinned refusal.
44
+ const pair = {
45
+ name: "pair",
46
+ description: "Manage pending channel pairing requests (admin)",
47
+ args: "[approve <code> <user> | deny <code>]",
48
+ run: async (ctx) => ({
49
+ notice: await runPair(engine.pairDeps(host), ctx.args, {
50
+ user: ctx.principal.user,
51
+ admin: ctx.principal.admin,
52
+ }),
53
+ }),
54
+ };
55
+ // `/link <channel>` (§8.2): issue a single-use self-link code bound
56
+ // to the invoking user and channel. `knownChannels` reads the ids the
57
+ // last start recorded — an engine that never started knows none, and
58
+ // every channel id is unknown (the honest refusal).
59
+ const link = createLinkCommand({
60
+ readConfig: () => readConfig(host),
61
+ linkCodes: engine.accessState().linkCodes,
62
+ knownChannels: engine.channelIds,
63
+ now: Date.now,
64
+ });
65
+ return {
66
+ extension: defineExtension({
67
+ name: "gateway",
68
+ tools: createGatewayTools(engine.toolDeps()),
69
+ sections: [createChannelSection(engine.sectionDeps())],
70
+ }),
71
+ gateway: engine,
72
+ // The settled-run fan-out (§8.6): boot registers this on the event
73
+ // bus — one call per settled run, which enqueues the finals and
74
+ // kicks the workers. The narrowed document read is mandatory:
75
+ // unlike the host bridge, it resolves in harness-only embedders
76
+ // without a 30 s wait.
77
+ runSettled: (event, api, context) => engine.settle(event, api, context),
78
+ interactionSettled: async (event, _api, context) => {
79
+ await engine.interactionSettled(event, context);
80
+ },
81
+ // Task 12 owns links as well as homes; Task 13 moves links to core's
82
+ // account table and narrows this cleanup to the home-chat records.
83
+ userDeleted: async ({ user }) => {
84
+ await engine.accessState().identities.forgetHomes(user);
85
+ },
86
+ // Home-chat notifications share the delivery ledger and workers;
87
+ // this wrapper kicks delivery after each bounded enqueue.
88
+ notifiers: [engine.notifier()],
89
+ // The admin status route (§8.12): mounts at /v1/ext/gateway/status,
90
+ // admin-gated by core's route mounting (the handler renders the
91
+ // engine's read; `pairing` rows carry the hashed sender id, never
92
+ // the code).
93
+ routes: [
94
+ createStatusRoute(() => ({
95
+ channels: engine.status().channels,
96
+ pairing: engine.pairingStatus(),
97
+ })),
98
+ ],
99
+ commands: [pair, link],
100
+ };
101
+ }
102
+ export { createEngine } from "./engine.js";
103
+ export { collectAttachments, TOO_LARGE_NOTE, VOICE_NOTE, ATTACHMENT_FAILED_NOTE } from "./inbound/media.js";
104
+ export { createGatewayTools, RECEIPT_WAIT_MS } from "./tools.js";
105
+ export { createChannelSection } from "./section.js";
106
+ export { splitMarkdown, stripMarkdown } from "./outbound/markdown.js";
107
+ export { renderChunks } from "./outbound/render.js";
108
+ export { Ledger, PRUNE_AFTER_MS } from "./state/ledger.js";
109
+ export { DeliveryWorker, RESENT_MARKER } from "./outbound/delivery.js";
110
+ export { onRunSettled, NO_TEXT_REPLY, STOPPED } from "./outbound/settled.js";
111
+ export { parseTarget, resolveTargets } from "./jobs.js";
112
+ export { createNotifier } from "./notify.js";
113
+ export { StreamRegistry } from "./outbound/stream.js";
114
+ export { Supervisor } from "./supervisor.js";
115
+ export { createStatusRoute } from "./status-route.js";
116
+ export { parseGatewayConfig, readConfig, DEFAULT_MEDIA_MAX_BYTES, } from "./config.js";
117
+ export { JsonFile } from "./state/json-file.js";
118
+ export { bindingKey, BindingStore } from "./state/bindings.js";
119
+ export { SeenSet } from "./state/seen.js";
120
+ export { IdentityStore } from "./state/identities.js";
121
+ export { buildEnvelope } from "./inbound/envelope.js";
122
+ export { createPipeline } from "./inbound/pipeline.js";
123
+ export { AlbumCollector, ALBUM_WINDOW_MS } from "./inbound/albums.js";
124
+ export { ButtonTokens } from "./inbound/tokens.js";
125
+ export { CODE_ALPHABET, newCode, normalizeCode } from "./access/codes.js";
126
+ export { PairingStore, PAIRING_TTL_MS } from "./access/pairing.js";
127
+ export { LinkCodes } from "./access/link-codes.js";
128
+ export { LoopGuard } from "./access/loop-guard.js";
129
+ export { runPair } from "./commands/pair.js";
130
+ export { createLinkCommand, redeemLink } from "./commands/link.js";
131
+ export { GATEWAY_COMMANDS, chatCommandMenu, handleChatCommand, } from "./commands/chat.js";
132
+ export { createLog, hashId } from "./log.js";
133
+ export { planInteraction, renderInteraction } from "./interactions/render.js";
134
+ export { PromptStore } from "./interactions/prompts.js";
135
+ export { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
136
+ export { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Write-gated answers originating in chat. Identity resolution belongs
3
+ * to the pipeline/tap caller; this module independently re-checks
4
+ * conversation write access immediately before answering.
5
+ */
6
+ import type { JsonValue } from "@sigrun/shared/blocks";
7
+ import type { HandlerContext, InboundEvent, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import { type BindingStore } from "../state/bindings.js";
9
+ import { type IdentityStore } from "../state/identities.js";
10
+ import type { PromptStore } from "./prompts.js";
11
+ export interface AnswerDeps {
12
+ readonly host: SigrunHost;
13
+ readonly bindings: BindingStore;
14
+ readonly identities: IdentityStore;
15
+ readonly prompts: PromptStore;
16
+ readonly context?: HandlerContext;
17
+ /** Direct adapter reply (the pipeline supplies it; unit callers need not). */
18
+ readonly reply?: (event: InboundEvent, text: string) => Promise<void>;
19
+ }
20
+ export interface ChatAnswer {
21
+ readonly conversationId: number;
22
+ readonly callId: string;
23
+ readonly values: Record<string, JsonValue>;
24
+ readonly user: string | undefined;
25
+ }
26
+ /** Answer after proving the named chat user can still write the conversation. */
27
+ export declare function answerFromChat(deps: AnswerDeps, answer: ChatAnswer): Promise<string>;
28
+ /** One shared human-readable outcome for settlement edits and stale taps. */
29
+ export declare function outcomeOf(record: {
30
+ readonly status: string;
31
+ readonly answer?: {
32
+ readonly values: Readonly<Record<string, JsonValue>>;
33
+ };
34
+ readonly answeredByUser?: string;
35
+ }): string;
36
+ export declare function summarizeValues(values: Readonly<Record<string, JsonValue>>): string;
37
+ /** Stage 8: consume a numbered or reply-to answer, else continue admission. */
38
+ export declare function createInteractionAnswerStage(deps: AnswerDeps & {
39
+ readonly configOf?: (channel: string) => Promise<import("../config.js").ChannelConfig | undefined>;
40
+ }): (channelId: string, event: InboundEvent) => Promise<{
41
+ outcome: "command";
42
+ } | undefined>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Write-gated answers originating in chat. Identity resolution belongs
3
+ * to the pipeline/tap caller; this module independently re-checks
4
+ * conversation write access immediately before answering.
5
+ */
6
+ import { bindingKey } from "../state/bindings.js";
7
+ import { chatProviderOf } from "../state/identities.js";
8
+ const TERMINAL = new Set(["answered", "skipped", "expired", "cancelled"]);
9
+ /** Answer after proving the named chat user can still write the conversation. */
10
+ export async function answerFromChat(deps, answer) {
11
+ if (answer.user === undefined)
12
+ return "You do not have write access to this conversation.";
13
+ const meta = await deps.host.conversations.meta(answer.conversationId);
14
+ const access = await deps.host.access.accessOf({ user: answer.user }, { owner: meta.owner, sharing: meta.sharing });
15
+ if (access !== "write")
16
+ return "You do not have write access to this conversation.";
17
+ // Terminal-snapshot invariant: secret-bearing records are webui-plan,
18
+ // so no chat answer reaches a secret record. The gateway owns NO
19
+ // secret delivery regardless — this correlation/terminal read only
20
+ // INSPECTS, so it is a peek (non-consuming, masked) by construction.
21
+ const record = await deps.host.peekInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
22
+ if (record !== undefined && TERMINAL.has(record.status)) {
23
+ return `Already ${outcomeOf(record)}.`;
24
+ }
25
+ if (record === undefined)
26
+ return "That interaction is no longer available.";
27
+ const answered = await deps.host.answerInteraction(answer.conversationId, answer.callId, { values: answer.values }, deps.context ?? { abortSignal: new AbortController().signal }, { user: answer.user });
28
+ if (!answered) {
29
+ // A lost race: someone else's answer won. NEVER snapshotInteraction
30
+ // here — that read takes the WINNER's one-shot secret delivery
31
+ // (Review Focus 4: the delivery belongs to the winner's extension
32
+ // read; a losing path consuming it would destroy the secret).
33
+ return "Already answered.";
34
+ }
35
+ return `Answered: ${summarizeValues(answer.values)}.`;
36
+ }
37
+ /** One shared human-readable outcome for settlement edits and stale taps. */
38
+ export function outcomeOf(record) {
39
+ const who = record.answeredByUser === undefined ? "" : ` by ${record.answeredByUser}`;
40
+ if (record.status === "answered") {
41
+ return `answered${who}: ${summarizeValues(record.answer?.values ?? {})}`;
42
+ }
43
+ return `${record.status}${who}`;
44
+ }
45
+ export function summarizeValues(values) {
46
+ const parts = Object.entries(values).map(([field, value]) => {
47
+ const rendered = typeof value === "string" ? value : JSON.stringify(value);
48
+ return `${field}=${rendered ?? "null"}`;
49
+ });
50
+ return parts.length === 0 ? "no values" : parts.join(", ");
51
+ }
52
+ /** Stage 8: consume a numbered or reply-to answer, else continue admission. */
53
+ export function createInteractionAnswerStage(deps) {
54
+ return async (channelId, event) => {
55
+ const key = bindingKey(channelId, event.chat);
56
+ const config = await deps.configOf?.(channelId);
57
+ const resolved = config === undefined
58
+ ? await deps.host.accounts.lookup(chatProviderOf(channelId), event.sender.id)
59
+ : await deps.identities.userFor(config, channelId, event.sender.id);
60
+ const user = resolved?.user;
61
+ const lookupKeys = user === undefined ? [key] : [key, `user:${user}`];
62
+ const lookup = (read) => {
63
+ for (const candidate of lookupKeys) {
64
+ const found = read(candidate);
65
+ if (found !== undefined)
66
+ return found;
67
+ }
68
+ return undefined;
69
+ };
70
+ let prompt;
71
+ const replyTarget = event.replyTo?.messageId;
72
+ if (replyTarget !== undefined) {
73
+ const replied = lookup((candidate) => deps.prompts.byReplyTo(candidate, replyTarget));
74
+ if (replied?.plan.kind === "text") {
75
+ prompt = {
76
+ conversationId: replied.conversationId,
77
+ callId: replied.callId,
78
+ values: { [replied.plan.field]: event.text },
79
+ };
80
+ }
81
+ }
82
+ if (prompt === undefined && /^\d+$/.test(event.text.trim())) {
83
+ const pending = lookup((candidate) => {
84
+ const rows = deps.prompts.pendingInChat(candidate);
85
+ return rows.length === 0 ? undefined : rows;
86
+ });
87
+ if (pending?.length === 1) {
88
+ const selected = pending[0];
89
+ const plan = selected.plan;
90
+ if (plan.kind !== "choice")
91
+ return undefined;
92
+ const index = Number(event.text.trim()) - 1;
93
+ const options = [...plan.options].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true));
94
+ const option = options[index];
95
+ if (option === undefined) {
96
+ await deps.reply?.(event, `Answer number out of range (1-${options.length}).`);
97
+ return { outcome: "command" };
98
+ }
99
+ prompt = {
100
+ conversationId: selected.conversationId,
101
+ callId: selected.callId,
102
+ values: { [plan.field]: option.id },
103
+ };
104
+ }
105
+ }
106
+ if (prompt === undefined)
107
+ return undefined;
108
+ const reply = await answerFromChat(deps, { ...prompt, user });
109
+ await deps.reply?.(event, reply);
110
+ return { outcome: "command" };
111
+ };
112
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Interaction notification and settlement fan-out. Handlers only enqueue
3
+ * durable ledger entries; delivery receipts populate PromptStore refs so
4
+ * settlement can edit the exact platform messages.
5
+ */
6
+ import type { ChannelContribution, ExtensionLifecyclePayload, HandlerContext, NotifierContribution } from "@sigrun/sigrun/extension";
7
+ import type { Ledger } from "../state/ledger.js";
8
+ import type { ButtonTokens } from "../inbound/tokens.js";
9
+ import type { EngineLog } from "../log.js";
10
+ import { type AnswerDeps } from "./answer.js";
11
+ export interface InteractionChatDeps extends AnswerDeps {
12
+ readonly ledger: Ledger;
13
+ readonly tokens: ButtonTokens;
14
+ readonly channelOf: (channel: string) => ChannelContribution | undefined;
15
+ readonly log: EngineLog;
16
+ }
17
+ /** The gateway notifier: interaction.pending plus the ordinary home notifier. */
18
+ export declare function createInteractionNotifier(deps: InteractionChatDeps): NotifierContribution;
19
+ /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
20
+ export declare function onInteractionSettled(event: ExtensionLifecyclePayload["interactionSettled"], deps: InteractionChatDeps, context: HandlerContext): Promise<void>;
21
+ /**
22
+ * Enqueue settlement edits and per-binding fallback outcome notices
23
+ * (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
24
+ * path when a real delivery ref arrives for an ALREADY-settled prompt:
25
+ * settlement that beats the receipt leaves no editable ref, so the
26
+ * late receipt is the last chance to replace the live buttons.
27
+ */
28
+ export declare function enqueueSettlementEdits(deps: InteractionChatDeps, conversationId: number, callId: string, outcome: string): Promise<void>;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Interaction notification and settlement fan-out. Handlers only enqueue
3
+ * durable ledger entries; delivery receipts populate PromptStore refs so
4
+ * settlement can edit the exact platform messages.
5
+ */
6
+ import { summarizeValues } from "./answer.js";
7
+ import { planInteraction, renderInteraction, truncateInteractionText } from "./render.js";
8
+ /** The gateway notifier: interaction.pending plus the ordinary home notifier. */
9
+ export function createInteractionNotifier(deps) {
10
+ return {
11
+ name: "gateway",
12
+ notify: async (event) => {
13
+ if (event.type !== "interaction.pending")
14
+ return;
15
+ if (event.conversationId === undefined || event.callId === undefined)
16
+ return;
17
+ try {
18
+ await notifyInteraction(event, deps);
19
+ }
20
+ catch (error) {
21
+ deps.log.error(`interaction prompt failed (${event.conversationId}:${event.callId})`, error);
22
+ }
23
+ },
24
+ };
25
+ }
26
+ async function notifyInteraction(event, deps) {
27
+ const keys = deps.bindings.keysOf(event.conversationId);
28
+ const targets = [];
29
+ if (keys.length > 0) {
30
+ for (const key of keys) {
31
+ const binding = deps.bindings.get(key);
32
+ if (binding === undefined || binding.dead === true)
33
+ continue;
34
+ targets.push({ key, channel: key.split(":")[0], chat: binding.chat, bindingKey: key });
35
+ }
36
+ }
37
+ else if (event.user !== undefined) {
38
+ for (const home of deps.identities.homesOf(event.user)) {
39
+ targets.push({ key: `user:${event.user}`, channel: home.channel, chat: home.chat });
40
+ }
41
+ }
42
+ // Resolve and dedupe targets BEFORE reading the record. The read is a
43
+ // PEEK: the gateway owns NO secret delivery (secret records are
44
+ // webui-plan), so it must never consume one — peekInteraction is the
45
+ // non-consuming inspection read (a settled secret's one-shot delivery
46
+ // belongs to the owning extension's snapshotInteraction alone).
47
+ const pendingTargets = targets.filter((target) => !deps.prompts.has(event.conversationId, event.callId, target.key));
48
+ if (pendingTargets.length === 0)
49
+ return;
50
+ const context = deps.context ?? { abortSignal: new AbortController().signal };
51
+ const record = await deps.host.peekInteraction(event.conversationId, event.callId, context);
52
+ if (record === undefined || record.status !== "pending")
53
+ return;
54
+ // A settle can land while the render above awaited: re-check BEFORE
55
+ // the first enqueue so an already-settled interaction never gets a
56
+ // live prompt (the buttons would only answer "Already answered.").
57
+ const still = await deps.host.peekInteraction(event.conversationId, event.callId, context);
58
+ if (still === undefined || still.status !== "pending")
59
+ return;
60
+ for (const target of pendingTargets) {
61
+ const channel = deps.channelOf(target.channel);
62
+ if (channel === undefined) {
63
+ deps.log.warn(`interaction prompt skipped: no channel "${target.channel}"`);
64
+ continue;
65
+ }
66
+ const plan = planInteraction(still, channel.capabilities);
67
+ const rendered = renderInteraction(still, channel.capabilities, {
68
+ conversationId: event.conversationId,
69
+ conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
70
+ ...(event.url === undefined ? {} : { link: event.url }),
71
+ issue: (action) => deps.tokens.issue(action.kind === "interaction"
72
+ ? { ...action, conversationId: event.conversationId }
73
+ : action),
74
+ });
75
+ // The ledger enqueue precedes the prompt row: the ledger tag is
76
+ // the durable dedupe, so a throwing enqueue must NOT leave a row
77
+ // that would suppress this target on every replay.
78
+ // Re-check per target immediately before its enqueue: rendering
79
+ // and meta lookups await, and a settle in that window must not
80
+ // deliver another live prompt. markEnqueued independently checks
81
+ // the durable call-level settlement marker as the final guard.
82
+ const current = await deps.host.peekInteraction(event.conversationId, event.callId, context);
83
+ if (current === undefined || current.status !== "pending")
84
+ return;
85
+ const enqueued = await deps.ledger.enqueue({
86
+ channel: target.channel,
87
+ ...(target.bindingKey === undefined ? {} : { bindingKey: target.bindingKey }),
88
+ chat: target.chat,
89
+ kind: "interaction",
90
+ message: { chat: target.chat, ...rendered },
91
+ tag: `interaction:${event.conversationId}:${event.callId}:${target.key}`,
92
+ });
93
+ await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
94
+ // A settlement between the pending peek and markEnqueued writes the
95
+ // call-level tombstone, so no row was reserved. Compensate the lost
96
+ // reservation by cancelling the still-pending prompt: otherwise its
97
+ // delivery would leave live buttons and duplicate the outcome notice.
98
+ if (enqueued !== undefined && !deps.prompts.has(event.conversationId, event.callId, target.key)) {
99
+ await deps.ledger.cancel(enqueued.id);
100
+ return;
101
+ }
102
+ }
103
+ }
104
+ /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
105
+ export async function onInteractionSettled(event, deps, context) {
106
+ try {
107
+ // NEVER snapshotInteraction here: that read is the one-shot SECRET
108
+ // delivery (it merges the true values back once, then deletes the
109
+ // entry) — an outcome edit that consumed it would silently destroy
110
+ // the extension's only chance to receive the secret. The widened C6
111
+ // event carries everything chat rendering needs (the stored answer
112
+ // is masked by construction — secrets never persist).
113
+ const outcome = outcomeText(event);
114
+ await enqueueSettlementEdits(deps, event.conversationId, event.callId, outcome);
115
+ await deps.prompts.settle(event.conversationId, event.callId, outcome);
116
+ }
117
+ catch (error) {
118
+ deps.log.error(`interaction settlement delivery failed (${String(event.conversationId)}:${event.callId})`, error);
119
+ }
120
+ }
121
+ /** The outcome summary the settlement edit/notice carries. */
122
+ function outcomeText(event) {
123
+ const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
124
+ // SAFETY: the stored answer's values are validated JsonValue (the
125
+ // record's answer shape); the payload types them loosely because
126
+ // the contract cannot import the json module's runtime shape.
127
+ const answerValues = (event.answer ?? {});
128
+ return event.status === "answered"
129
+ ? `answered${who}: ${summarizeValues(answerValues)}`
130
+ : `${event.status}${who}`;
131
+ }
132
+ /**
133
+ * Enqueue settlement edits and per-binding fallback outcome notices
134
+ * (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
135
+ * path when a real delivery ref arrives for an ALREADY-settled prompt:
136
+ * settlement that beats the receipt leaves no editable ref, so the
137
+ * late receipt is the last chance to replace the live buttons.
138
+ */
139
+ export async function enqueueSettlementEdits(deps, conversationId, callId, outcome) {
140
+ const editableBindings = new Set();
141
+ const refs = deps.prompts.refsOf(conversationId, callId);
142
+ for (const ref of refs) {
143
+ const id = channelForRef(deps, conversationId, callId, ref);
144
+ const channel = id === undefined ? undefined : deps.channelOf(id);
145
+ const bindingKey = deps.bindings.keysOf(conversationId).find((key) => {
146
+ const binding = deps.bindings.get(key);
147
+ return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
148
+ });
149
+ if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
150
+ if (bindingKey !== undefined)
151
+ editableBindings.add(bindingKey);
152
+ await deps.ledger.enqueue({
153
+ channel: channel.id,
154
+ ...(bindingKey === undefined ? {} : { bindingKey }),
155
+ chat: ref.chat,
156
+ kind: "edit",
157
+ ref,
158
+ message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
159
+ tag: `interaction-settled:${conversationId}:${callId}:${channel.id}:${ref.messageId}`,
160
+ });
161
+ }
162
+ }
163
+ // Fallback eligibility is PER BINDING: an editable ref on one channel
164
+ // must not suppress the outcome notice owed to another binding whose
165
+ // channel cannot edit (or whose prompt has no editable ref).
166
+ for (const key of deps.bindings.keysOf(conversationId)) {
167
+ if (editableBindings.has(key))
168
+ continue;
169
+ const binding = deps.bindings.get(key);
170
+ const channelId = key.split(":")[0];
171
+ if (binding === undefined || binding.dead === true)
172
+ continue;
173
+ await deps.ledger.enqueue({
174
+ channel: channelId,
175
+ bindingKey: key,
176
+ chat: binding.chat,
177
+ kind: "notice",
178
+ message: { chat: binding.chat, text: `Interaction ${outcome}.` },
179
+ tag: `interaction-settled-notice:${conversationId}:${callId}:${key}`,
180
+ });
181
+ }
182
+ }
183
+ /** Recover the channel from the binding key/home prompt target. */
184
+ function channelForRef(deps, conversationId, callId, ref) {
185
+ for (const key of deps.bindings.keysOf(conversationId)) {
186
+ const binding = deps.bindings.get(key);
187
+ if (binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId)
188
+ return key.split(":")[0];
189
+ }
190
+ return promptChannel(deps, conversationId, callId, ref);
191
+ }
192
+ function promptChannel(deps, conversationId, callId, ref) {
193
+ // PromptStore persists refs, not a second channel mapping. The ledger's
194
+ // interaction entry is the authoritative channel attribution.
195
+ const entry = deps.ledger.list().find((candidate) => candidate.kind === "interaction" &&
196
+ candidate.tag?.startsWith(`interaction:${conversationId}:${callId}:`) === true &&
197
+ candidate.chat.chatId === ref.chat.chatId);
198
+ return entry?.channel;
199
+ }