@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +218 -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 +111 -0
- package/dist/interactions/chat.d.ts +20 -0
- package/dist/interactions/chat.js +155 -0
- package/dist/interactions/prompts.d.ts +42 -0
- package/dist/interactions/prompts.js +96 -0
- package/dist/interactions/receipts.d.ts +6 -0
- package/dist/interactions/receipts.js +36 -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 +57 -0
- package/dist/outbound/delivery.d.ts +124 -0
- package/dist/outbound/delivery.js +387 -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 +80 -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 +8 -0
- package/dist/session-meta.js +21 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +61 -0
- package/dist/state/identities.js +131 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +152 -0
- package/dist/state/ledger.js +207 -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
package/dist/index.d.ts
ADDED
|
@@ -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,111 @@
|
|
|
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 this consuming read. Widening the planner
|
|
19
|
+
// requires a non-consuming snapshot before any terminal path reads one.
|
|
20
|
+
const record = await deps.host.snapshotInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
|
|
21
|
+
if (record !== undefined && TERMINAL.has(record.status)) {
|
|
22
|
+
return `Already ${outcomeOf(record)}.`;
|
|
23
|
+
}
|
|
24
|
+
if (record === undefined)
|
|
25
|
+
return "That interaction is no longer available.";
|
|
26
|
+
const answered = await deps.host.answerInteraction(answer.conversationId, answer.callId, { values: answer.values }, deps.context ?? { abortSignal: new AbortController().signal }, { user: answer.user });
|
|
27
|
+
if (!answered) {
|
|
28
|
+
// A lost race: someone else's answer won. NEVER snapshotInteraction
|
|
29
|
+
// here — that read takes the WINNER's one-shot secret delivery
|
|
30
|
+
// (Review Focus 4: the delivery belongs to the winner's extension
|
|
31
|
+
// read; a losing path consuming it would destroy the secret).
|
|
32
|
+
return "Already answered.";
|
|
33
|
+
}
|
|
34
|
+
return `Answered: ${summarizeValues(answer.values)}.`;
|
|
35
|
+
}
|
|
36
|
+
/** One shared human-readable outcome for settlement edits and stale taps. */
|
|
37
|
+
export function outcomeOf(record) {
|
|
38
|
+
const who = record.answeredByUser === undefined ? "" : ` by ${record.answeredByUser}`;
|
|
39
|
+
if (record.status === "answered") {
|
|
40
|
+
return `answered${who}: ${summarizeValues(record.answer?.values ?? {})}`;
|
|
41
|
+
}
|
|
42
|
+
return `${record.status}${who}`;
|
|
43
|
+
}
|
|
44
|
+
export function summarizeValues(values) {
|
|
45
|
+
const parts = Object.entries(values).map(([field, value]) => {
|
|
46
|
+
const rendered = typeof value === "string" ? value : JSON.stringify(value);
|
|
47
|
+
return `${field}=${rendered ?? "null"}`;
|
|
48
|
+
});
|
|
49
|
+
return parts.length === 0 ? "no values" : parts.join(", ");
|
|
50
|
+
}
|
|
51
|
+
/** Stage 8: consume a numbered or reply-to answer, else continue admission. */
|
|
52
|
+
export function createInteractionAnswerStage(deps) {
|
|
53
|
+
return async (channelId, event) => {
|
|
54
|
+
const key = bindingKey(channelId, event.chat);
|
|
55
|
+
const config = await deps.configOf?.(channelId);
|
|
56
|
+
const resolved = config === undefined
|
|
57
|
+
? await deps.host.accounts.lookup(chatProviderOf(channelId), event.sender.id)
|
|
58
|
+
: await deps.identities.userFor(config, channelId, event.sender.id);
|
|
59
|
+
const user = resolved?.user;
|
|
60
|
+
const lookupKeys = user === undefined ? [key] : [key, `user:${user}`];
|
|
61
|
+
const lookup = (read) => {
|
|
62
|
+
for (const candidate of lookupKeys) {
|
|
63
|
+
const found = read(candidate);
|
|
64
|
+
if (found !== undefined)
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
let prompt;
|
|
70
|
+
const replyTarget = event.replyTo?.messageId;
|
|
71
|
+
if (replyTarget !== undefined) {
|
|
72
|
+
const replied = lookup((candidate) => deps.prompts.byReplyTo(candidate, replyTarget));
|
|
73
|
+
if (replied?.plan.kind === "text") {
|
|
74
|
+
prompt = {
|
|
75
|
+
conversationId: replied.conversationId,
|
|
76
|
+
callId: replied.callId,
|
|
77
|
+
values: { [replied.plan.field]: event.text },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (prompt === undefined && /^\d+$/.test(event.text.trim())) {
|
|
82
|
+
const pending = lookup((candidate) => {
|
|
83
|
+
const rows = deps.prompts.pendingInChat(candidate);
|
|
84
|
+
return rows.length === 0 ? undefined : rows;
|
|
85
|
+
});
|
|
86
|
+
if (pending?.length === 1) {
|
|
87
|
+
const selected = pending[0];
|
|
88
|
+
const plan = selected.plan;
|
|
89
|
+
if (plan.kind !== "choice")
|
|
90
|
+
return undefined;
|
|
91
|
+
const index = Number(event.text.trim()) - 1;
|
|
92
|
+
const options = [...plan.options].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true));
|
|
93
|
+
const option = options[index];
|
|
94
|
+
if (option === undefined) {
|
|
95
|
+
await deps.reply?.(event, `Answer number out of range (1-${options.length}).`);
|
|
96
|
+
return { outcome: "command" };
|
|
97
|
+
}
|
|
98
|
+
prompt = {
|
|
99
|
+
conversationId: selected.conversationId,
|
|
100
|
+
callId: selected.callId,
|
|
101
|
+
values: { [plan.field]: option.id },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (prompt === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
const reply = await answerFromChat(deps, { ...prompt, user });
|
|
108
|
+
await deps.reply?.(event, reply);
|
|
109
|
+
return { outcome: "command" };
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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>;
|
|
@@ -0,0 +1,155 @@
|
|
|
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. snapshotInteraction
|
|
43
|
+
// consumes a settled secret's one-shot delivery, even when we then return.
|
|
44
|
+
// Final-review follow-up: core should offer a non-consuming snapshot read.
|
|
45
|
+
const pendingTargets = targets.filter((target) => !deps.prompts.has(event.conversationId, event.callId, target.key));
|
|
46
|
+
if (pendingTargets.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
const context = deps.context ?? { abortSignal: new AbortController().signal };
|
|
49
|
+
const record = await deps.host.snapshotInteraction(event.conversationId, event.callId, context);
|
|
50
|
+
if (record === undefined || record.status !== "pending")
|
|
51
|
+
return;
|
|
52
|
+
for (const target of pendingTargets) {
|
|
53
|
+
const channel = deps.channelOf(target.channel);
|
|
54
|
+
if (channel === undefined) {
|
|
55
|
+
deps.log.warn(`interaction prompt skipped: no channel "${target.channel}"`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const plan = planInteraction(record, channel.capabilities);
|
|
59
|
+
const rendered = renderInteraction(record, channel.capabilities, {
|
|
60
|
+
conversationId: event.conversationId,
|
|
61
|
+
conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
|
|
62
|
+
...(event.url === undefined ? {} : { link: event.url }),
|
|
63
|
+
issue: (action) => deps.tokens.issue(action.kind === "interaction"
|
|
64
|
+
? { ...action, conversationId: event.conversationId }
|
|
65
|
+
: action),
|
|
66
|
+
});
|
|
67
|
+
await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
|
|
68
|
+
await deps.ledger.enqueue({
|
|
69
|
+
channel: target.channel,
|
|
70
|
+
...(target.bindingKey === undefined ? {} : { bindingKey: target.bindingKey }),
|
|
71
|
+
chat: target.chat,
|
|
72
|
+
kind: "interaction",
|
|
73
|
+
message: { chat: target.chat, ...rendered },
|
|
74
|
+
tag: `interaction:${event.conversationId}:${event.callId}:${target.key}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
|
|
79
|
+
export async function onInteractionSettled(event, deps, context) {
|
|
80
|
+
try {
|
|
81
|
+
// NEVER snapshotInteraction here: that read is the one-shot SECRET
|
|
82
|
+
// delivery (it merges the true values back once, then deletes the
|
|
83
|
+
// entry) — an outcome edit that consumed it would silently destroy
|
|
84
|
+
// the extension's only chance to receive the secret. The widened C6
|
|
85
|
+
// event carries everything chat rendering needs (the stored answer
|
|
86
|
+
// is masked by construction — secrets never persist).
|
|
87
|
+
const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
|
|
88
|
+
// SAFETY: the stored answer's values are validated JsonValue (the
|
|
89
|
+
// record's answer shape); the payload types them loosely because
|
|
90
|
+
// the contract cannot import the json module's runtime shape.
|
|
91
|
+
const answerValues = (event.answer ?? {});
|
|
92
|
+
const outcome = event.status === "answered"
|
|
93
|
+
? `answered${who}: ${summarizeValues(answerValues)}`
|
|
94
|
+
: `${event.status}${who}`;
|
|
95
|
+
const editableChannels = new Set();
|
|
96
|
+
const refs = deps.prompts.refsOf(event.conversationId, event.callId);
|
|
97
|
+
for (const ref of refs) {
|
|
98
|
+
const id = channelForRef(deps, event.conversationId, event.callId, ref);
|
|
99
|
+
const channel = id === undefined ? undefined : deps.channelOf(id);
|
|
100
|
+
if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
|
|
101
|
+
editableChannels.add(channel.id);
|
|
102
|
+
const bindingKey = deps.bindings.keysOf(event.conversationId).find((key) => {
|
|
103
|
+
const binding = deps.bindings.get(key);
|
|
104
|
+
return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
|
|
105
|
+
});
|
|
106
|
+
await deps.ledger.enqueue({
|
|
107
|
+
channel: channel.id,
|
|
108
|
+
...(bindingKey === undefined ? {} : { bindingKey }),
|
|
109
|
+
chat: ref.chat,
|
|
110
|
+
kind: "edit",
|
|
111
|
+
ref,
|
|
112
|
+
message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
|
|
113
|
+
tag: `interaction-settled:${event.conversationId}:${event.callId}:${channel.id}:${ref.messageId}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (editableChannels.size === 0) {
|
|
118
|
+
for (const key of deps.bindings.keysOf(event.conversationId)) {
|
|
119
|
+
const binding = deps.bindings.get(key);
|
|
120
|
+
const channelId = key.split(":")[0];
|
|
121
|
+
if (binding === undefined || binding.dead === true)
|
|
122
|
+
continue;
|
|
123
|
+
await deps.ledger.enqueue({
|
|
124
|
+
channel: channelId,
|
|
125
|
+
bindingKey: key,
|
|
126
|
+
chat: binding.chat,
|
|
127
|
+
kind: "notice",
|
|
128
|
+
message: { chat: binding.chat, text: `Interaction ${outcome}.` },
|
|
129
|
+
tag: `interaction-settled-notice:${event.conversationId}:${event.callId}:${key}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await deps.prompts.settle(event.conversationId, event.callId);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
deps.log.error(`interaction settlement delivery failed (${String(event.conversationId)}:${event.callId})`, error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Recover the channel from the binding key/home prompt target. */
|
|
140
|
+
function channelForRef(deps, conversationId, callId, ref) {
|
|
141
|
+
for (const key of deps.bindings.keysOf(conversationId)) {
|
|
142
|
+
const binding = deps.bindings.get(key);
|
|
143
|
+
if (binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId)
|
|
144
|
+
return key.split(":")[0];
|
|
145
|
+
}
|
|
146
|
+
return promptChannel(deps, conversationId, callId, ref);
|
|
147
|
+
}
|
|
148
|
+
function promptChannel(deps, conversationId, callId, ref) {
|
|
149
|
+
// PromptStore persists refs, not a second channel mapping. The ledger's
|
|
150
|
+
// interaction entry is the authoritative channel attribution.
|
|
151
|
+
const entry = deps.ledger.list().find((candidate) => candidate.kind === "interaction" &&
|
|
152
|
+
candidate.tag?.startsWith(`interaction:${conversationId}:${callId}:`) === true &&
|
|
153
|
+
candidate.chat.chatId === ref.chat.chatId);
|
|
154
|
+
return entry?.channel;
|
|
155
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable chat-prompt bookkeeping. Ledger delivery receipts become
|
|
3
|
+
* MessageRefs here; pending reads stay synchronous for inbound stage 8.
|
|
4
|
+
* Settled prompts stop matching numbered/reply-to answers; their refs
|
|
5
|
+
* remain so settlement can edit the delivered platform message.
|
|
6
|
+
*/
|
|
7
|
+
import type { MessageRef } from "@sigrun/sigrun/extension";
|
|
8
|
+
import type { ChatAnswerPlan } from "./render.js";
|
|
9
|
+
export interface PendingPrompt {
|
|
10
|
+
readonly conversationId: number;
|
|
11
|
+
readonly callId: string;
|
|
12
|
+
readonly plan: ChatAnswerPlan;
|
|
13
|
+
}
|
|
14
|
+
export declare class PromptStore {
|
|
15
|
+
private readonly file;
|
|
16
|
+
private rows;
|
|
17
|
+
private settled;
|
|
18
|
+
constructor(path: string);
|
|
19
|
+
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
20
|
+
record(conversationId: number, callId: string, key: string, ref: MessageRef, plan: ChatAnswerPlan): Promise<void>;
|
|
21
|
+
/** All delivered message refs for settlement edits (including settled). */
|
|
22
|
+
refsOf(conversationId: number, callId: string): readonly MessageRef[];
|
|
23
|
+
/** Whether this recipient/chat already has a prompt recorded. */
|
|
24
|
+
has(conversationId: number, callId: string, key: string): boolean;
|
|
25
|
+
/** Recipient identities for opaque-key receipt correlation. */
|
|
26
|
+
pendingRecipients(conversationId: number): readonly {
|
|
27
|
+
callId: string;
|
|
28
|
+
key: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** Recipient keys for receipt correlation (opaque binding keys may contain colons). */
|
|
31
|
+
keysOf(conversationId: number, callId: string): readonly string[];
|
|
32
|
+
/** Unsettled choice/text prompts in one chat (bound key or user home key). */
|
|
33
|
+
pendingInChat(bindingKey: string): readonly PendingPrompt[];
|
|
34
|
+
/** Find the prompt behind a reply-to platform message id. */
|
|
35
|
+
byReplyTo(bindingKey: string, messageId: string): PendingPrompt | undefined;
|
|
36
|
+
/** A recipient has been enqueued even before its receipt supplies a ref. */
|
|
37
|
+
markEnqueued(conversationId: number, callId: string, key: string, plan: ChatAnswerPlan): Promise<void>;
|
|
38
|
+
/** Install the real refs after the interaction ledger entry is delivered. */
|
|
39
|
+
recordReceipt(conversationId: number, callId: string, key: string, chat: MessageRef["chat"], messageIds: readonly string[]): Promise<void>;
|
|
40
|
+
/** Retire pending answers while preserving refs for the settlement edit. */
|
|
41
|
+
settle(conversationId: number, callId: string): Promise<void>;
|
|
42
|
+
}
|