@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
|
@@ -0,0 +1,96 @@
|
|
|
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 { JsonFile } from "../state/json-file.js";
|
|
8
|
+
export class PromptStore {
|
|
9
|
+
file;
|
|
10
|
+
rows;
|
|
11
|
+
settled;
|
|
12
|
+
constructor(path) {
|
|
13
|
+
this.file = new JsonFile(path, () => ({ prompts: [], settled: [] }));
|
|
14
|
+
const state = this.file.readSync();
|
|
15
|
+
this.rows = state.prompts;
|
|
16
|
+
this.settled = new Set(state.settled);
|
|
17
|
+
}
|
|
18
|
+
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
19
|
+
async record(conversationId, callId, key, ref, plan) {
|
|
20
|
+
const state = await this.file.update((draft) => {
|
|
21
|
+
const identity = `${conversationId}:${callId}:${key}`;
|
|
22
|
+
// A settlement raced the receipt: preserve the newly delivered
|
|
23
|
+
// ref for the edit, never re-open the prompt for answers.
|
|
24
|
+
if (draft.settled.includes(identity) && ref.messageId === "")
|
|
25
|
+
return;
|
|
26
|
+
const existing = draft.prompts.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
27
|
+
const next = { conversationId, callId, key, ref, plan };
|
|
28
|
+
if (existing === undefined)
|
|
29
|
+
draft.prompts.push(next);
|
|
30
|
+
else
|
|
31
|
+
draft.prompts[draft.prompts.indexOf(existing)] = next;
|
|
32
|
+
});
|
|
33
|
+
this.rows = state.prompts;
|
|
34
|
+
this.settled = new Set(state.settled);
|
|
35
|
+
}
|
|
36
|
+
/** All delivered message refs for settlement edits (including settled). */
|
|
37
|
+
refsOf(conversationId, callId) {
|
|
38
|
+
return this.rows
|
|
39
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
40
|
+
.map((row) => row.ref);
|
|
41
|
+
}
|
|
42
|
+
/** Whether this recipient/chat already has a prompt recorded. */
|
|
43
|
+
has(conversationId, callId, key) {
|
|
44
|
+
return this.rows.some((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
45
|
+
}
|
|
46
|
+
/** Recipient identities for opaque-key receipt correlation. */
|
|
47
|
+
pendingRecipients(conversationId) {
|
|
48
|
+
return this.rows
|
|
49
|
+
.filter((row) => row.conversationId === conversationId)
|
|
50
|
+
.map((row) => ({ callId: row.callId, key: row.key }));
|
|
51
|
+
}
|
|
52
|
+
/** Recipient keys for receipt correlation (opaque binding keys may contain colons). */
|
|
53
|
+
keysOf(conversationId, callId) {
|
|
54
|
+
return this.rows
|
|
55
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
56
|
+
.map((row) => row.key);
|
|
57
|
+
}
|
|
58
|
+
/** Unsettled choice/text prompts in one chat (bound key or user home key). */
|
|
59
|
+
pendingInChat(bindingKey) {
|
|
60
|
+
return this.rows
|
|
61
|
+
.filter((row) => row.key === bindingKey && !this.settled.has(identityOf(row)))
|
|
62
|
+
.map((row) => ({ conversationId: row.conversationId, callId: row.callId, plan: row.plan }));
|
|
63
|
+
}
|
|
64
|
+
/** Find the prompt behind a reply-to platform message id. */
|
|
65
|
+
byReplyTo(bindingKey, messageId) {
|
|
66
|
+
const row = this.rows.find((candidate) => candidate.key === bindingKey &&
|
|
67
|
+
candidate.ref.messageId === messageId &&
|
|
68
|
+
!this.settled.has(identityOf(candidate)));
|
|
69
|
+
return row === undefined
|
|
70
|
+
? undefined
|
|
71
|
+
: { conversationId: row.conversationId, callId: row.callId, plan: row.plan };
|
|
72
|
+
}
|
|
73
|
+
/** A recipient has been enqueued even before its receipt supplies a ref. */
|
|
74
|
+
async markEnqueued(conversationId, callId, key, plan) {
|
|
75
|
+
await this.record(conversationId, callId, key, { chat: { chatId: "", chatType: "dm" }, messageId: "" }, plan);
|
|
76
|
+
}
|
|
77
|
+
/** Install the real refs after the interaction ledger entry is delivered. */
|
|
78
|
+
async recordReceipt(conversationId, callId, key, chat, messageIds) {
|
|
79
|
+
const existing = this.rows.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
80
|
+
await this.record(conversationId, callId, key, { chat, messageId: messageIds[0] ?? "" }, existing?.plan ?? { kind: "webui" });
|
|
81
|
+
}
|
|
82
|
+
/** Retire pending answers while preserving refs for the settlement edit. */
|
|
83
|
+
async settle(conversationId, callId) {
|
|
84
|
+
const state = await this.file.update((draft) => {
|
|
85
|
+
const keys = draft.prompts
|
|
86
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
87
|
+
.map(identityOf);
|
|
88
|
+
draft.settled = [...new Set([...draft.settled, ...keys])];
|
|
89
|
+
});
|
|
90
|
+
this.rows = state.prompts;
|
|
91
|
+
this.settled = new Set(state.settled);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function identityOf(row) {
|
|
95
|
+
return `${row.conversationId}:${row.callId}:${row.key}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
|
+
import type { LedgerEntry } from "../state/ledger.js";
|
|
3
|
+
import type { PromptStore } from "./prompts.js";
|
|
4
|
+
export declare function receiptForInteraction(prompts: PromptStore, entry: LedgerEntry, receipt: {
|
|
5
|
+
readonly messageIds: readonly string[];
|
|
6
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
|
+
export async function receiptForInteraction(prompts, entry, receipt) {
|
|
3
|
+
// The receipt is authoritative: an early settlement may have removed
|
|
4
|
+
// the pending row, but the delivered message still needs an edit ref.
|
|
5
|
+
if (entry.bindingKey !== undefined) {
|
|
6
|
+
const match = entry.tag?.match(/^interaction:(\d+):(.+)$/);
|
|
7
|
+
if (match === null || match === undefined)
|
|
8
|
+
return;
|
|
9
|
+
const suffix = `:${entry.bindingKey}`;
|
|
10
|
+
if (!match[2].endsWith(suffix))
|
|
11
|
+
return;
|
|
12
|
+
const callId = match[2].slice(0, -suffix.length);
|
|
13
|
+
await prompts.recordReceipt(Number(match[1]), callId, entry.bindingKey, entry.chat, receipt.messageIds);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const tag = entry.tag;
|
|
17
|
+
if (tag === undefined || !tag.startsWith("interaction:"))
|
|
18
|
+
return;
|
|
19
|
+
const rest = tag.slice("interaction:".length);
|
|
20
|
+
const separator = rest.indexOf(":");
|
|
21
|
+
if (separator < 0)
|
|
22
|
+
return;
|
|
23
|
+
const conversationId = Number(rest.slice(0, separator));
|
|
24
|
+
const afterConversation = rest.slice(separator + 1);
|
|
25
|
+
if (!Number.isInteger(conversationId))
|
|
26
|
+
return;
|
|
27
|
+
// Unbound home keys have no ledger bindingKey; the pending rows'
|
|
28
|
+
// callIds are the other side of the persisted identity.
|
|
29
|
+
for (const row of prompts.pendingRecipients(conversationId)) {
|
|
30
|
+
const suffix = `${row.callId}:${row.key}`;
|
|
31
|
+
if (afterConversation !== suffix)
|
|
32
|
+
continue;
|
|
33
|
+
await prompts.recordReceipt(conversationId, row.callId, row.key, entry.chat, receipt.messageIds);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic chat presentation for a generic interaction record.
|
|
3
|
+
* The planner admits only answers chat can represent faithfully; every
|
|
4
|
+
* other root form stays in the webui (notably secrets and open choices).
|
|
5
|
+
*/
|
|
6
|
+
import { type ChoiceOption } from "@sigrun/shared/blocks";
|
|
7
|
+
import type { InteractionRecord } from "@sigrun/shared/interactions";
|
|
8
|
+
import type { ChannelCapabilities, OutboundMessage } from "@sigrun/sigrun/extension";
|
|
9
|
+
import type { TokenAction } from "../inbound/tokens.js";
|
|
10
|
+
/** The answer shapes a chat can collect without losing form semantics. */
|
|
11
|
+
export type ChatAnswerPlan = {
|
|
12
|
+
readonly kind: "choice";
|
|
13
|
+
readonly field: string;
|
|
14
|
+
readonly options: readonly ChoiceOption[];
|
|
15
|
+
readonly buttons: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: "text";
|
|
18
|
+
readonly field: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: "webui";
|
|
21
|
+
};
|
|
22
|
+
export interface RenderInteractionOptions {
|
|
23
|
+
readonly conversationName: string;
|
|
24
|
+
readonly link?: string;
|
|
25
|
+
/** The conversation stamped into each interaction token. */
|
|
26
|
+
readonly conversationId?: number;
|
|
27
|
+
/** Mint the opaque transport-private action carried by each button. */
|
|
28
|
+
readonly issue: (action: TokenAction & {
|
|
29
|
+
readonly kind: "interaction";
|
|
30
|
+
}) => string;
|
|
31
|
+
}
|
|
32
|
+
/** Plan the one answer mechanism the record's root form supports in chat. */
|
|
33
|
+
export declare function planInteraction(record: InteractionRecord, caps: ChannelCapabilities): ChatAnswerPlan;
|
|
34
|
+
/** Render the generic record and its chat-specific answer affordance. */
|
|
35
|
+
export declare function renderInteraction(record: InteractionRecord, caps: ChannelCapabilities, opts: RenderInteractionOptions): {
|
|
36
|
+
text: string;
|
|
37
|
+
buttons?: OutboundMessage["buttons"];
|
|
38
|
+
};
|
|
39
|
+
export declare function truncateInteractionText(text: string, max: number): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic chat presentation for a generic interaction record.
|
|
3
|
+
* The planner admits only answers chat can represent faithfully; every
|
|
4
|
+
* other root form stays in the webui (notably secrets and open choices).
|
|
5
|
+
*/
|
|
6
|
+
import { toText } from "@sigrun/shared/blocks";
|
|
7
|
+
import { renderChunks } from "../outbound/render.js";
|
|
8
|
+
/** Plan the one answer mechanism the record's root form supports in chat. */
|
|
9
|
+
export function planInteraction(record, caps) {
|
|
10
|
+
const root = record.body?.root;
|
|
11
|
+
if (root?.type !== "form")
|
|
12
|
+
return { kind: "webui" };
|
|
13
|
+
const fields = root.children.filter((child) => child.type !== "section");
|
|
14
|
+
// A section can carry arbitrary presentation/field nesting. Planning
|
|
15
|
+
// only the flat root form avoids claiming a subset of a richer form.
|
|
16
|
+
if (fields.length !== root.children.length)
|
|
17
|
+
return { kind: "webui" };
|
|
18
|
+
const choices = fields.filter((field) => field.type === "choice");
|
|
19
|
+
if (choices.length === 1 &&
|
|
20
|
+
fields.every((field) => field.type === "choice" ||
|
|
21
|
+
((field.type === "text" || field.type === "longtext") && field.required !== true))) {
|
|
22
|
+
const choice = choices[0];
|
|
23
|
+
if (choice.multi !== true &&
|
|
24
|
+
choice.allowOther === undefined &&
|
|
25
|
+
Array.isArray(choice.options)) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "choice",
|
|
28
|
+
field: choice.name,
|
|
29
|
+
options: choice.options,
|
|
30
|
+
buttons: choice.options.length <= (caps.buttons?.max ?? 0),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (fields.length === 1 && (fields[0].type === "text" || fields[0].type === "longtext")) {
|
|
35
|
+
return { kind: "text", field: fields[0].name };
|
|
36
|
+
}
|
|
37
|
+
return { kind: "webui" };
|
|
38
|
+
}
|
|
39
|
+
/** Render the generic record and its chat-specific answer affordance. */
|
|
40
|
+
export function renderInteraction(record, caps, opts) {
|
|
41
|
+
const plan = planInteraction(record, caps);
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (record.body !== undefined) {
|
|
44
|
+
const projected = plan.kind === "choice" ? withoutFormFields(record.body) : record.body;
|
|
45
|
+
const text = toText(projected, {}, { width: 60, locale: "en" });
|
|
46
|
+
if (text !== "")
|
|
47
|
+
lines.push(text);
|
|
48
|
+
}
|
|
49
|
+
if (opts.conversationName !== "")
|
|
50
|
+
lines.push(`Conversation: ${opts.conversationName}`);
|
|
51
|
+
let buttons;
|
|
52
|
+
if (plan.kind === "choice") {
|
|
53
|
+
const options = [...plan.options].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true));
|
|
54
|
+
const label = choiceLabel(record.body, plan.field);
|
|
55
|
+
if (label !== "")
|
|
56
|
+
lines.push(label);
|
|
57
|
+
if (plan.buttons) {
|
|
58
|
+
const row = options.map((option) => ({
|
|
59
|
+
label: truncateLabel(option.label, caps.buttons?.labelMax),
|
|
60
|
+
data: opts.issue({
|
|
61
|
+
kind: "interaction",
|
|
62
|
+
field: plan.field,
|
|
63
|
+
conversationId: opts.conversationId ?? 0,
|
|
64
|
+
callId: record.callId,
|
|
65
|
+
optionId: option.id,
|
|
66
|
+
}),
|
|
67
|
+
...(option.tone === "bad" ? { style: "danger" } : {}),
|
|
68
|
+
}));
|
|
69
|
+
const perRow = Math.max(1, caps.buttons?.perRow ?? row.length);
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (let index = 0; index < row.length; index += perRow)
|
|
72
|
+
rows.push(row.slice(index, index + perRow));
|
|
73
|
+
buttons = rows;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
options.forEach((option, index) => lines.push(`${index + 1}. ${option.label}`));
|
|
77
|
+
lines.push("Reply with a number.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (plan.kind === "text") {
|
|
81
|
+
lines.push("Reply to this message to answer.");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push("Answer this in the webui");
|
|
85
|
+
if (opts.link !== undefined)
|
|
86
|
+
lines.push(opts.link);
|
|
87
|
+
}
|
|
88
|
+
// The body projection is plain text; the capability formatter still
|
|
89
|
+
// runs so native/plain channels get the same §8.6 treatment as finals.
|
|
90
|
+
const formatted = renderChunks(lines.join("\n"), caps).join("\n");
|
|
91
|
+
return {
|
|
92
|
+
text: truncateInteractionText(formatted, caps.maxMessageLength),
|
|
93
|
+
...(buttons === undefined ? {} : { buttons }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Omit form fields when buttons/the numbered list replace their rendering. */
|
|
97
|
+
function withoutFormFields(tree) {
|
|
98
|
+
if (tree.root.type !== "form")
|
|
99
|
+
return tree;
|
|
100
|
+
const root = { ...tree.root, children: tree.root.children.filter((child) => child.type === "section") };
|
|
101
|
+
return { ...tree, root };
|
|
102
|
+
}
|
|
103
|
+
function choiceLabel(body, name) {
|
|
104
|
+
if (body?.root.type !== "form")
|
|
105
|
+
return "";
|
|
106
|
+
const field = body.root.children.find((child) => child.type === "choice" && child.name === name);
|
|
107
|
+
return field?.type === "choice" && typeof field.label === "string" ? field.label : "";
|
|
108
|
+
}
|
|
109
|
+
function truncateLabel(label, max) {
|
|
110
|
+
// Literal options are a planner requirement, so their labels are text.
|
|
111
|
+
const text = typeof label === "string" ? label : "";
|
|
112
|
+
return max === undefined ? text : text.slice(0, Math.max(0, max));
|
|
113
|
+
}
|
|
114
|
+
export function truncateInteractionText(text, max) {
|
|
115
|
+
const limit = Math.max(1, max);
|
|
116
|
+
if (text.length <= limit)
|
|
117
|
+
return text;
|
|
118
|
+
const note = "\n… full details in the webui";
|
|
119
|
+
if (limit <= note.length)
|
|
120
|
+
return text.slice(0, limit);
|
|
121
|
+
return `${text.slice(0, limit - note.length).trimEnd()}${note}`;
|
|
122
|
+
}
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job delivery targets (spec §8.8): a channel alone means the owner's
|
|
3
|
+
* home DM; an explicit chat must be that home DM or a chat bound to a
|
|
4
|
+
* conversation the owner can write. This policy gate is shared by job
|
|
5
|
+
* delivery and the chat-facing message tools.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChatRef, SigrunHost } from "@sigrun/sigrun/extension";
|
|
8
|
+
import type { BindingStore } from "./state/bindings.js";
|
|
9
|
+
import type { IdentityStore } from "./state/identities.js";
|
|
10
|
+
export interface DeliveryTarget {
|
|
11
|
+
readonly channel: string;
|
|
12
|
+
readonly chatId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ResolvedTarget {
|
|
15
|
+
readonly channel: string;
|
|
16
|
+
readonly chat: ChatRef;
|
|
17
|
+
}
|
|
18
|
+
export interface RejectedTarget {
|
|
19
|
+
readonly target: string;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ResolveTargetsResult {
|
|
23
|
+
readonly resolved: readonly ResolvedTarget[];
|
|
24
|
+
readonly rejected: readonly RejectedTarget[];
|
|
25
|
+
}
|
|
26
|
+
export interface TargetDeps {
|
|
27
|
+
readonly host: SigrunHost;
|
|
28
|
+
readonly bindings: BindingStore;
|
|
29
|
+
readonly identities: IdentityStore;
|
|
30
|
+
}
|
|
31
|
+
/** Parse `<channel>` or `<channel>:<chatId>`; the chat id is opaque. */
|
|
32
|
+
export declare function parseTarget(target: string): DeliveryTarget | undefined;
|
|
33
|
+
/** Resolve and authorize each target independently; no target aborts the rest. */
|
|
34
|
+
export declare function resolveTargets(deps: TargetDeps, owner: string, targets: readonly string[]): Promise<ResolveTargetsResult>;
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job delivery targets (spec §8.8): a channel alone means the owner's
|
|
3
|
+
* home DM; an explicit chat must be that home DM or a chat bound to a
|
|
4
|
+
* conversation the owner can write. This policy gate is shared by job
|
|
5
|
+
* delivery and the chat-facing message tools.
|
|
6
|
+
*/
|
|
7
|
+
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
8
|
+
/** Parse `<channel>` or `<channel>:<chatId>`; the chat id is opaque. */
|
|
9
|
+
export function parseTarget(target) {
|
|
10
|
+
const separator = target.indexOf(":");
|
|
11
|
+
const channel = separator === -1 ? target : target.slice(0, separator);
|
|
12
|
+
const chatId = separator === -1 ? undefined : target.slice(separator + 1);
|
|
13
|
+
if (!CHANNEL_PATTERN.test(channel) || chatId === "")
|
|
14
|
+
return undefined;
|
|
15
|
+
return chatId === undefined ? { channel } : { channel, chatId };
|
|
16
|
+
}
|
|
17
|
+
/** Resolve and authorize each target independently; no target aborts the rest. */
|
|
18
|
+
export async function resolveTargets(deps, owner, targets) {
|
|
19
|
+
const resolved = [];
|
|
20
|
+
const rejected = [];
|
|
21
|
+
const homes = deps.identities.homesOf(owner);
|
|
22
|
+
const bindings = deps.bindings.entries();
|
|
23
|
+
for (const target of targets) {
|
|
24
|
+
const parsed = parseTarget(target);
|
|
25
|
+
if (parsed === undefined) {
|
|
26
|
+
rejected.push({ target, reason: "invalid target grammar" });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const home = homes.find((entry) => entry.channel === parsed.channel);
|
|
30
|
+
if (parsed.chatId === undefined) {
|
|
31
|
+
if (home === undefined) {
|
|
32
|
+
rejected.push({ target, reason: `no home chat on channel "${parsed.channel}"` });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
resolved.push({ channel: parsed.channel, chat: home.chat });
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (home?.chat.chatId === parsed.chatId) {
|
|
40
|
+
resolved.push({ channel: parsed.channel, chat: home.chat });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const binding = findChatBinding(bindings, parsed.channel, parsed.chatId);
|
|
44
|
+
if (binding?.dead === true) {
|
|
45
|
+
rejected.push({ target, reason: "chat is gone" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const meta = binding === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: await deps.host.conversations.meta(binding.conversationId);
|
|
51
|
+
const access = meta === undefined
|
|
52
|
+
? "none"
|
|
53
|
+
: await deps.host.access.accessOf({ user: owner }, { owner: meta.owner, sharing: meta.sharing });
|
|
54
|
+
if (binding === undefined || access !== "write") {
|
|
55
|
+
rejected.push({ target, reason: "chat is not bound to a writable conversation" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
resolved.push({ channel: parsed.channel, chat: binding.chat });
|
|
59
|
+
}
|
|
60
|
+
return { resolved, rejected };
|
|
61
|
+
}
|
|
62
|
+
/** Find a chat without guessing its thread id or group/DM type. */
|
|
63
|
+
function findChatBinding(bindings, channel, chatId) {
|
|
64
|
+
const prefix = `${channel}:default:`;
|
|
65
|
+
for (const [key, binding] of Object.entries(bindings)) {
|
|
66
|
+
if (!key.startsWith(prefix))
|
|
67
|
+
continue;
|
|
68
|
+
if (binding?.chat.chatId === chatId)
|
|
69
|
+
return binding;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine logging + id hashing (spec §8.0/§8.12): one scoped console
|
|
3
|
+
* logger with a per-process `once` dedupe (per-key — a repeated warn
|
|
4
|
+
* fires a single line, never one per event), and `hashId` — the
|
|
5
|
+
* non-reversible form sender ids take in status output.
|
|
6
|
+
*/
|
|
7
|
+
/** The irreversible form a platform sender id takes in operator-facing
|
|
8
|
+
* output: the sha256 digest's first 8 hex chars (the plan's pinned
|
|
9
|
+
* format — enough to correlate without leaking the raw platform id). */
|
|
10
|
+
export declare function hashId(id: string): string;
|
|
11
|
+
/** One scoped logger: console methods plus a per-key `once`. */
|
|
12
|
+
export interface EngineLog {
|
|
13
|
+
info(msg: string): void;
|
|
14
|
+
warn(msg: string): void;
|
|
15
|
+
error(msg: string, error?: unknown): void;
|
|
16
|
+
debug(msg: string): void;
|
|
17
|
+
/** Emit `msg` at `level` ONCE per process per `key` (per-key dedupe). */
|
|
18
|
+
once(key: string, level: "info" | "warn", msg: string): void;
|
|
19
|
+
}
|
|
20
|
+
/** Build the scoped console logger (the `gateway:` prefix names the engine). */
|
|
21
|
+
export declare function createLog(scope: string): EngineLog;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine logging + id hashing (spec §8.0/§8.12): one scoped console
|
|
3
|
+
* logger with a per-process `once` dedupe (per-key — a repeated warn
|
|
4
|
+
* fires a single line, never one per event), and `hashId` — the
|
|
5
|
+
* non-reversible form sender ids take in status output.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
/** The irreversible form a platform sender id takes in operator-facing
|
|
9
|
+
* output: the sha256 digest's first 8 hex chars (the plan's pinned
|
|
10
|
+
* format — enough to correlate without leaking the raw platform id). */
|
|
11
|
+
export function hashId(id) {
|
|
12
|
+
return createHash("sha256").update(id).digest("hex").slice(0, 8);
|
|
13
|
+
}
|
|
14
|
+
/** The emitted-once keys this process has already logged. */
|
|
15
|
+
const onceLogged = new Set();
|
|
16
|
+
/** Build the scoped console logger (the `gateway:` prefix names the engine). */
|
|
17
|
+
export function createLog(scope) {
|
|
18
|
+
const prefix = `gateway: ${scope}`.trim();
|
|
19
|
+
return {
|
|
20
|
+
info: (msg) => console.info(`${prefix} ${msg}`),
|
|
21
|
+
warn: (msg) => console.warn(`${prefix} ${msg}`),
|
|
22
|
+
error: (msg, error) => {
|
|
23
|
+
if (error === undefined)
|
|
24
|
+
console.error(`${prefix} ${msg}`);
|
|
25
|
+
else
|
|
26
|
+
console.error(`${prefix} ${msg}`, error);
|
|
27
|
+
},
|
|
28
|
+
// debug rides the console's debug channel: visible when the
|
|
29
|
+
// environment enables it, silent otherwise.
|
|
30
|
+
debug: (msg) => console.debug(`${prefix} ${msg}`),
|
|
31
|
+
once: (key, level, msg) => {
|
|
32
|
+
const onceKey = `${prefix} ${key}`;
|
|
33
|
+
if (onceLogged.has(onceKey))
|
|
34
|
+
return;
|
|
35
|
+
onceLogged.add(onceKey);
|
|
36
|
+
if (level === "info")
|
|
37
|
+
console.info(`${prefix} ${msg}`);
|
|
38
|
+
else
|
|
39
|
+
console.warn(`${prefix} ${msg}`);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home-chat notifier (spec §8.8): core chooses the recipients; the
|
|
3
|
+
* engine turns each event into durable ledger entries for that user's
|
|
4
|
+
* home DMs. Handlers only enqueue — channel workers own platform I/O.
|
|
5
|
+
*/
|
|
6
|
+
import type { NotifierContribution, SigrunHost } from "@sigrun/sigrun/extension";
|
|
7
|
+
import type { BindingStore } from "./state/bindings.js";
|
|
8
|
+
import type { IdentityStore } from "./state/identities.js";
|
|
9
|
+
import type { Ledger } from "./state/ledger.js";
|
|
10
|
+
import type { GatewayConfig } from "./config.js";
|
|
11
|
+
import type { EngineLog } from "./log.js";
|
|
12
|
+
export interface NotifyDeps {
|
|
13
|
+
readonly host: SigrunHost;
|
|
14
|
+
readonly bindings: BindingStore;
|
|
15
|
+
readonly identities: IdentityStore;
|
|
16
|
+
readonly ledger: Ledger;
|
|
17
|
+
readonly readConfig: () => Promise<GatewayConfig>;
|
|
18
|
+
readonly log: EngineLog;
|
|
19
|
+
}
|
|
20
|
+
/** Build the factory-result notifier over the engine's shared stores. */
|
|
21
|
+
export declare function createNotifier(deps: NotifyDeps): NotifierContribution;
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home-chat notifier (spec §8.8): core chooses the recipients; the
|
|
3
|
+
* engine turns each event into durable ledger entries for that user's
|
|
4
|
+
* home DMs. Handlers only enqueue — channel workers own platform I/O.
|
|
5
|
+
*/
|
|
6
|
+
/** Build the factory-result notifier over the engine's shared stores. */
|
|
7
|
+
export function createNotifier(deps) {
|
|
8
|
+
return {
|
|
9
|
+
name: "home",
|
|
10
|
+
notify: async (event) => {
|
|
11
|
+
try {
|
|
12
|
+
await notify(event, deps);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
// A notice must never break core's other notifier deliveries;
|
|
16
|
+
// the event is best-effort, but its loss is always visible.
|
|
17
|
+
deps.log.error(`home notification failed (${event.type})`, error);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function notify(event, deps) {
|
|
23
|
+
// Task 9 owns chat presentation for interaction records.
|
|
24
|
+
if (event.type === "interaction.pending")
|
|
25
|
+
return;
|
|
26
|
+
if (event.user === undefined)
|
|
27
|
+
return;
|
|
28
|
+
const config = await deps.readConfig();
|
|
29
|
+
if (!config.notify.home)
|
|
30
|
+
return;
|
|
31
|
+
if (event.type === "run.settled") {
|
|
32
|
+
if (event.conversationId === undefined)
|
|
33
|
+
return;
|
|
34
|
+
// The settled fan-out owns bound conversations; a second notice
|
|
35
|
+
// here would duplicate the answer the chat just received.
|
|
36
|
+
if (deps.bindings.keysOf(event.conversationId).length > 0)
|
|
37
|
+
return;
|
|
38
|
+
const meta = await deps.host.conversations.meta(event.conversationId);
|
|
39
|
+
// New-conversation jobs are unbound, but their delivery and
|
|
40
|
+
// job.failed paths own notices. Core's run.settled carries no
|
|
41
|
+
// outcome, so the false success notice on a failed job run needs
|
|
42
|
+
// a core-contract event change and is ledgered for final review.
|
|
43
|
+
if (meta.jobId !== undefined)
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const text = event.type === "run.settled"
|
|
47
|
+
? `Reply ready — ${event.body}${event.url === undefined ? "" : `\n${event.url}`}`
|
|
48
|
+
: `${event.title}\n${event.body}${event.url === undefined ? "" : `\n${event.url}`}`;
|
|
49
|
+
for (const home of deps.identities.homesOf(event.user)) {
|
|
50
|
+
await deps.ledger.enqueue({
|
|
51
|
+
channel: home.channel,
|
|
52
|
+
chat: home.chat,
|
|
53
|
+
kind: "notice",
|
|
54
|
+
message: { chat: home.chat, text },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|