@osolmaz/pi-workflows 0.16.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/dist/channels/adapter-entry.d.ts +3 -0
- package/dist/channels/adapter-entry.js +203 -0
- package/dist/channels/adapter-entry.js.map +1 -0
- package/dist/channels/config.d.ts +43 -0
- package/dist/channels/config.js +234 -0
- package/dist/channels/config.js.map +1 -0
- package/dist/channels/protocol.d.ts +94 -0
- package/dist/channels/protocol.js +219 -0
- package/dist/channels/protocol.js.map +1 -0
- package/dist/channels/telegram.d.ts +46 -0
- package/dist/channels/telegram.js +285 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/client/client.d.ts +1 -0
- package/dist/client/client.js +5 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/protocol.d.ts +1 -1
- package/dist/client/protocol.js +11 -5
- package/dist/client/protocol.js.map +1 -1
- package/dist/client/view.d.ts +48 -16
- package/dist/client/view.js +1 -0
- package/dist/client/view.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +20 -165
- package/dist/controllers/sqlite.js +178 -436
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.d.ts +23 -2
- package/dist/extension/index.js +637 -534
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +95 -0
- package/dist/extension/recorder.js +530 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/remote-recorder-store.d.ts +25 -0
- package/dist/extension/remote-recorder-store.js +81 -0
- package/dist/extension/remote-recorder-store.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/session-view.d.ts +2 -0
- package/dist/extension/session-view.js +60 -2
- package/dist/extension/session-view.js.map +1 -1
- package/dist/extension/step-message.d.ts +6 -4
- package/dist/extension/step-message.js +12 -6
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.d.ts +2 -2
- package/dist/extension/widget.js +28 -18
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +28 -0
- package/dist/extension/workflow-message-coordinator.js +346 -0
- package/dist/extension/workflow-message-coordinator.js.map +1 -0
- package/dist/host/channel-effects.d.ts +72 -0
- package/dist/host/channel-effects.js +271 -0
- package/dist/host/channel-effects.js.map +1 -0
- package/dist/host/channel-supervisor.d.ts +21 -0
- package/dist/host/channel-supervisor.js +54 -0
- package/dist/host/channel-supervisor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -8
- package/dist/host/runner.js +1632 -341
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +19 -16
- package/dist/host/state.js +251 -63
- package/dist/host/state.js.map +1 -1
- package/dist/host/view.d.ts +19 -9
- package/dist/host/view.js +227 -119
- package/dist/host/view.js.map +1 -1
- package/dist/host/worker-entry.d.ts +4 -1
- package/dist/host/worker-entry.js +23 -24
- package/dist/host/worker-entry.js.map +1 -1
- package/dist/host/worker-protocol.d.ts +15 -0
- package/dist/host/worker-protocol.js.map +1 -1
- package/dist/state/prune.js +3 -25
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.js +76 -97
- package/dist/state/schema.js.map +1 -1
- package/dist/state/workflow-messages.d.ts +98 -0
- package/dist/state/workflow-messages.js +366 -0
- package/dist/state/workflow-messages.js.map +1 -0
- package/dist/viewer/render.js +1 -1
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/tui.d.ts +1 -1
- package/dist/viewer/tui.js +65 -8
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/composition.js +0 -4
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.js +0 -8
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +4 -5
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +2 -0
- package/dist/workflows/human-decision.js +8 -2
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/settings.d.ts +1 -8
- package/dist/workflows/settings.js.map +1 -1
- package/dist/workflows/store.d.ts +20 -19
- package/dist/workflows/store.js +163 -354
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +21 -0
- package/dist/workflows/tool-input.js +23 -1
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +6 -8
- package/dist/workflows/workflow-message-content.d.ts +38 -0
- package/dist/workflows/workflow-message-content.js +157 -0
- package/dist/workflows/workflow-message-content.js.map +1 -0
- package/docs/2026-08-18-herdr-piw-plan.md +2 -1
- package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
- package/docs/2026-08-25-workflow-follow-ups.md +26 -50
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
- package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
- package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
- package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
- package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
- package/docs/DEFERRED_TURNS.md +66 -294
- package/docs/HUMAN_DECISIONS.md +29 -30
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
- package/docs/SQLITE_STATE.md +25 -17
- package/docs/WORKFLOW_HOST.md +105 -31
- package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
- package/docs/live-replay-protocol.md +8 -4
- package/docs/tui-viewer.md +3 -1
- package/docs/workflows.md +44 -28
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +14 -5
- package/src/channels/adapter-entry.ts +220 -0
- package/src/channels/config.ts +296 -0
- package/src/channels/protocol.ts +333 -0
- package/src/channels/telegram.ts +335 -0
- package/src/client/client.ts +6 -2
- package/src/client/index.ts +2 -1
- package/src/client/protocol.ts +11 -5
- package/src/client/view.ts +51 -18
- package/src/controllers/index.ts +0 -1
- package/src/controllers/sqlite.ts +247 -845
- package/src/extension/index.ts +712 -647
- package/src/extension/recorder.ts +687 -0
- package/src/extension/remote-recorder-store.ts +126 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/session-view.ts +86 -3
- package/src/extension/step-message.ts +19 -9
- package/src/extension/widget.ts +37 -17
- package/src/extension/workflow-message-coordinator.ts +398 -0
- package/src/host/channel-effects.ts +465 -0
- package/src/host/channel-supervisor.ts +73 -0
- package/src/host/runner.ts +2163 -525
- package/src/host/state.ts +333 -94
- package/src/host/view.ts +254 -122
- package/src/host/worker-entry.ts +38 -30
- package/src/host/worker-protocol.ts +11 -0
- package/src/state/prune.ts +3 -31
- package/src/state/schema.ts +76 -97
- package/src/state/workflow-messages.ts +599 -0
- package/src/viewer/render.ts +1 -5
- package/src/viewer/tui.ts +71 -6
- package/src/workflows/composition.ts +0 -5
- package/src/workflows/definition.ts +0 -8
- package/src/workflows/engine.ts +4 -6
- package/src/workflows/human-decision.ts +18 -2
- package/src/workflows/index.ts +0 -1
- package/src/workflows/schema.ts +0 -6
- package/src/workflows/settings.ts +1 -20
- package/src/workflows/store.ts +237 -489
- package/src/workflows/tool-input.ts +36 -1
- package/src/workflows/types.ts +6 -8
- package/src/workflows/workflow-message-content.ts +197 -0
- package/dist/extension/decision-channels.d.ts +0 -134
- package/dist/extension/decision-channels.js +0 -1307
- package/dist/extension/decision-channels.js.map +0 -1
- package/src/extension/decision-channels.ts +0 -1826
|
@@ -1,1826 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import fsp from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
-
import type { StateDatabase } from "../state/database.js";
|
|
8
|
-
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
9
|
-
import {
|
|
10
|
-
MAX_PRESENTATION_TRANSPORT_PARTS,
|
|
11
|
-
decisionDocumentSegments,
|
|
12
|
-
decisionPresentationFingerprint,
|
|
13
|
-
digestCanonical,
|
|
14
|
-
humanDecisionChannelRequest,
|
|
15
|
-
} from "../workflows/decision-presentation.js";
|
|
16
|
-
import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
|
|
17
|
-
import type {
|
|
18
|
-
ResolvedHumanDecision,
|
|
19
|
-
HumanDecisionAnswerSource,
|
|
20
|
-
HumanDecisionCancellationRecord,
|
|
21
|
-
HumanDecisionChannelRequest,
|
|
22
|
-
HumanDecisionDeliveryRecord,
|
|
23
|
-
HumanDecisionResponse,
|
|
24
|
-
HumanDecisionSettlementRecord,
|
|
25
|
-
} from "../workflows/types.js";
|
|
26
|
-
|
|
27
|
-
const TELEGRAM_TEXT_LIMIT = 4_096;
|
|
28
|
-
const PI_PRESENTATION_WINDOW_LINES = 18;
|
|
29
|
-
const DEFAULT_API_BASE = "https://api.telegram.org";
|
|
30
|
-
const LEASE_TTL_MS = 60_000;
|
|
31
|
-
const LEASE_RENEW_WINDOW_MS = 20_000;
|
|
32
|
-
const LEASE_RETRY_MS = 5_000;
|
|
33
|
-
const POLL_BACKOFF_MS = 1_000;
|
|
34
|
-
const MAX_SETTLEMENT_ATTEMPTS = 3;
|
|
35
|
-
const NUMERIC_ID = /^-?[0-9]+$/;
|
|
36
|
-
const SIMPLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
37
|
-
|
|
38
|
-
export type HumanDecisionChannelAnswer = {
|
|
39
|
-
request: HumanDecisionChannelRequest;
|
|
40
|
-
response: HumanDecisionResponse;
|
|
41
|
-
source: HumanDecisionAnswerSource;
|
|
42
|
-
idempotencyKey: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export type HumanDecisionDeliveryResult = {
|
|
46
|
-
status: "confirmed" | "failed" | "unknown";
|
|
47
|
-
channel: string;
|
|
48
|
-
attemptId: string;
|
|
49
|
-
errorCode?: string;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export type SettledHumanDecision = ResolvedHumanDecision | HumanDecisionCancellationRecord;
|
|
53
|
-
|
|
54
|
-
export interface HumanDecisionChannel {
|
|
55
|
-
readonly id: string;
|
|
56
|
-
start(): Promise<void>;
|
|
57
|
-
deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult>;
|
|
58
|
-
settle(decision: SettledHumanDecision): Promise<void>;
|
|
59
|
-
stop(): Promise<void>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export type DecisionChannelConfig = {
|
|
63
|
-
schema: "pi-workflows.channels.v1";
|
|
64
|
-
audiences: Record<string, { channels: string[]; accept: "first-valid-answer" }>;
|
|
65
|
-
telegramProfiles?: Record<
|
|
66
|
-
string,
|
|
67
|
-
{
|
|
68
|
-
credential: string;
|
|
69
|
-
allowedUserIds: string[];
|
|
70
|
-
allowedChatIds: string[];
|
|
71
|
-
}
|
|
72
|
-
>;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export type DecisionCredentialConfig = {
|
|
76
|
-
schema: "pi-workflows.credentials.v1";
|
|
77
|
-
telegram: Record<string, { tokenFile: string }>;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
type ResolvedDecisionCredentials = Record<string, string>;
|
|
81
|
-
|
|
82
|
-
export type LoadedDecisionChannelConfig = {
|
|
83
|
-
channels: DecisionChannelConfig;
|
|
84
|
-
credentials: ResolvedDecisionCredentials;
|
|
85
|
-
configDir: string;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
export type TelegramFetch = (
|
|
89
|
-
input: string,
|
|
90
|
-
init?: RequestInit,
|
|
91
|
-
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
|
|
92
|
-
|
|
93
|
-
export type PiDecisionUi = Pick<ExtensionContext["ui"], "custom" | "input">;
|
|
94
|
-
|
|
95
|
-
export function decisionConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
96
|
-
return env.PI_WORKFLOWS_CONFIG_DIR ?? path.join(os.homedir(), ".config", "pi-workflows");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function loadDecisionChannelConfig(
|
|
100
|
-
configDir = decisionConfigDir(),
|
|
101
|
-
): Promise<LoadedDecisionChannelConfig | null> {
|
|
102
|
-
const channelPath = path.join(configDir, "channels.json");
|
|
103
|
-
let channelsRaw: unknown;
|
|
104
|
-
try {
|
|
105
|
-
channelsRaw = JSON.parse(await fsp.readFile(channelPath, "utf8"));
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
110
|
-
await requirePrivateFile(channelPath, "pi-workflows channel profile");
|
|
111
|
-
const channels = parseChannelConfig(channelsRaw);
|
|
112
|
-
const profileNames = Object.keys(channels.telegramProfiles ?? {});
|
|
113
|
-
if (profileNames.length === 0) return { channels, credentials: {}, configDir };
|
|
114
|
-
|
|
115
|
-
const credentialPath = path.join(configDir, "credentials.json");
|
|
116
|
-
await requirePrivateFile(credentialPath, "pi-workflows credential profile");
|
|
117
|
-
const credentialConfig = parseCredentialConfig(
|
|
118
|
-
JSON.parse(await fsp.readFile(credentialPath, "utf8")),
|
|
119
|
-
);
|
|
120
|
-
const credentials: ResolvedDecisionCredentials = {};
|
|
121
|
-
for (const [name, credential] of Object.entries(credentialConfig.telegram)) {
|
|
122
|
-
if (!path.isAbsolute(credential.tokenFile)) {
|
|
123
|
-
throw new Error(`Telegram credential ${name} tokenFile must be absolute`);
|
|
124
|
-
}
|
|
125
|
-
await requirePrivateFile(credential.tokenFile, `Telegram credential ${name} token file`);
|
|
126
|
-
const token = (await fsp.readFile(credential.tokenFile, "utf8")).trim();
|
|
127
|
-
if (token.length === 0) throw new Error(`Telegram credential ${name} token file is empty`);
|
|
128
|
-
credentials[name] = token;
|
|
129
|
-
}
|
|
130
|
-
return { channels, credentials, configDir };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function audienceChannels(config: DecisionChannelConfig | null, audience: string): string[] {
|
|
134
|
-
const configured = config?.audiences[audience]?.channels;
|
|
135
|
-
return configured === undefined ? ["pi"] : [...configured];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export class PiDecisionChannel implements HumanDecisionChannel {
|
|
139
|
-
readonly id = "pi";
|
|
140
|
-
private promptAbort: AbortController | null = null;
|
|
141
|
-
private settlementTask: Promise<void> | null = null;
|
|
142
|
-
|
|
143
|
-
constructor(
|
|
144
|
-
private readonly options: {
|
|
145
|
-
actorId: string;
|
|
146
|
-
ui: PiDecisionUi;
|
|
147
|
-
store: HumanDecisionStore;
|
|
148
|
-
onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
|
|
149
|
-
},
|
|
150
|
-
) {}
|
|
151
|
-
|
|
152
|
-
async start(): Promise<void> {}
|
|
153
|
-
|
|
154
|
-
async stop(): Promise<void> {
|
|
155
|
-
this.promptAbort?.abort();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
|
|
159
|
-
if (await this.alreadyDecided(request.decisionId)) {
|
|
160
|
-
return { status: "confirmed", channel: this.id, attemptId: "adopted" };
|
|
161
|
-
}
|
|
162
|
-
const attemptId = createHumanDecisionAttemptId();
|
|
163
|
-
const createdAt = new Date().toISOString();
|
|
164
|
-
const controller = new AbortController();
|
|
165
|
-
this.promptAbort = controller;
|
|
166
|
-
try {
|
|
167
|
-
await this.options.store.recordDelivery(
|
|
168
|
-
request,
|
|
169
|
-
this.id,
|
|
170
|
-
deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
|
|
171
|
-
);
|
|
172
|
-
const entries = Object.entries(request.choices);
|
|
173
|
-
const selectedChoice = await this.options.ui.custom<string | undefined>(
|
|
174
|
-
(tui, theme, _keybindings, done) => {
|
|
175
|
-
let choiceIndex = 0;
|
|
176
|
-
let scroll = 0;
|
|
177
|
-
let settled = false;
|
|
178
|
-
const finish = (value: string | undefined) => {
|
|
179
|
-
if (settled) return;
|
|
180
|
-
settled = true;
|
|
181
|
-
done(value);
|
|
182
|
-
};
|
|
183
|
-
const abort = () => finish(undefined);
|
|
184
|
-
controller.signal.addEventListener("abort", abort, { once: true });
|
|
185
|
-
return {
|
|
186
|
-
render(width: number): string[] {
|
|
187
|
-
const content = renderPiPresentationLines(request, Math.max(20, width), theme);
|
|
188
|
-
const maxScroll = Math.max(0, content.length - PI_PRESENTATION_WINDOW_LINES);
|
|
189
|
-
scroll = Math.min(scroll, maxScroll);
|
|
190
|
-
const visible = content.slice(scroll, scroll + PI_PRESENTATION_WINDOW_LINES);
|
|
191
|
-
const lines = [...visible];
|
|
192
|
-
if (content.length > PI_PRESENTATION_WINDOW_LINES) {
|
|
193
|
-
lines.push(
|
|
194
|
-
theme.fg(
|
|
195
|
-
"dim",
|
|
196
|
-
`Decision text ${scroll + 1}-${Math.min(content.length, scroll + PI_PRESENTATION_WINDOW_LINES)}/${content.length} · PgUp/PgDn scroll`,
|
|
197
|
-
),
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
lines.push("");
|
|
201
|
-
for (const [index, [, definition]] of entries.entries()) {
|
|
202
|
-
const marker = index === choiceIndex ? "›" : " ";
|
|
203
|
-
lines.push(
|
|
204
|
-
...wrapTextWithAnsi(
|
|
205
|
-
`${theme.fg(index === choiceIndex ? "accent" : "text", `${marker} ${definition.label}`)}`,
|
|
206
|
-
Math.max(1, width),
|
|
207
|
-
),
|
|
208
|
-
);
|
|
209
|
-
if (definition.input !== undefined) {
|
|
210
|
-
lines.push(
|
|
211
|
-
...wrapTextWithAnsi(
|
|
212
|
-
theme.fg("dim", ` ${definition.input.prompt}`),
|
|
213
|
-
Math.max(1, width),
|
|
214
|
-
),
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
lines.push("");
|
|
219
|
-
lines.push(theme.fg("dim", "↑/↓ choose · Enter confirm · Esc cancel"));
|
|
220
|
-
return lines;
|
|
221
|
-
},
|
|
222
|
-
invalidate() {},
|
|
223
|
-
handleInput(data: string): void {
|
|
224
|
-
if (matchesKey(data, Key.escape)) finish(undefined);
|
|
225
|
-
else if (matchesKey(data, Key.enter)) finish(entries[choiceIndex]?.[0]);
|
|
226
|
-
else if (matchesKey(data, Key.up)) choiceIndex = Math.max(0, choiceIndex - 1);
|
|
227
|
-
else if (matchesKey(data, Key.down)) {
|
|
228
|
-
choiceIndex = Math.min(entries.length - 1, choiceIndex + 1);
|
|
229
|
-
} else if (matchesKey(data, Key.pageUp)) {
|
|
230
|
-
scroll = Math.max(0, scroll - PI_PRESENTATION_WINDOW_LINES);
|
|
231
|
-
} else if (matchesKey(data, Key.pageDown)) {
|
|
232
|
-
scroll += PI_PRESENTATION_WINDOW_LINES;
|
|
233
|
-
} else return;
|
|
234
|
-
tui.requestRender();
|
|
235
|
-
},
|
|
236
|
-
dispose(): void {
|
|
237
|
-
controller.signal.removeEventListener("abort", abort);
|
|
238
|
-
},
|
|
239
|
-
};
|
|
240
|
-
},
|
|
241
|
-
);
|
|
242
|
-
if (selectedChoice === undefined) {
|
|
243
|
-
const errorCode = controller.signal.aborted
|
|
244
|
-
? "pi_selection_settled_elsewhere"
|
|
245
|
-
: "pi_selection_cancelled";
|
|
246
|
-
await this.options.store.recordDelivery(
|
|
247
|
-
request,
|
|
248
|
-
this.id,
|
|
249
|
-
deliveryRecord(
|
|
250
|
-
request,
|
|
251
|
-
this.id,
|
|
252
|
-
`${attemptId}-cancelled`,
|
|
253
|
-
"complete",
|
|
254
|
-
"failed",
|
|
255
|
-
createdAt,
|
|
256
|
-
{ errorCode },
|
|
257
|
-
),
|
|
258
|
-
);
|
|
259
|
-
return { status: "failed", channel: this.id, attemptId, errorCode };
|
|
260
|
-
}
|
|
261
|
-
const definition = request.choices[selectedChoice];
|
|
262
|
-
if (definition === undefined) throw new Error("Pi decision selection is not in the request");
|
|
263
|
-
let response: HumanDecisionResponse = { choice: selectedChoice };
|
|
264
|
-
if (definition.input !== undefined) {
|
|
265
|
-
const text = await this.options.ui.input(definition.input.prompt, "", {
|
|
266
|
-
signal: controller.signal,
|
|
267
|
-
});
|
|
268
|
-
if (text === undefined) {
|
|
269
|
-
const errorCode = controller.signal.aborted
|
|
270
|
-
? "pi_input_settled_elsewhere"
|
|
271
|
-
: "pi_input_cancelled";
|
|
272
|
-
await this.options.store.recordDelivery(
|
|
273
|
-
request,
|
|
274
|
-
this.id,
|
|
275
|
-
deliveryRecord(
|
|
276
|
-
request,
|
|
277
|
-
this.id,
|
|
278
|
-
`${attemptId}-cancelled`,
|
|
279
|
-
"complete",
|
|
280
|
-
"failed",
|
|
281
|
-
createdAt,
|
|
282
|
-
{ errorCode },
|
|
283
|
-
),
|
|
284
|
-
);
|
|
285
|
-
return { status: "failed", channel: this.id, attemptId, errorCode };
|
|
286
|
-
}
|
|
287
|
-
response = { choice: selectedChoice, input: { [definition.input.name]: text } };
|
|
288
|
-
}
|
|
289
|
-
if (await this.alreadyDecided(request.decisionId)) {
|
|
290
|
-
return { status: "confirmed", channel: this.id, attemptId: "adopted" };
|
|
291
|
-
}
|
|
292
|
-
const eventId = createHumanDecisionAttemptId();
|
|
293
|
-
await this.options.onAnswer({
|
|
294
|
-
request,
|
|
295
|
-
response,
|
|
296
|
-
source: { channel: this.id, actorId: this.options.actorId, eventId },
|
|
297
|
-
idempotencyKey: `${this.id}:${eventId}`,
|
|
298
|
-
});
|
|
299
|
-
await this.options.store.recordDelivery(
|
|
300
|
-
request,
|
|
301
|
-
this.id,
|
|
302
|
-
deliveryRecord(
|
|
303
|
-
request,
|
|
304
|
-
this.id,
|
|
305
|
-
`${attemptId}-confirmed`,
|
|
306
|
-
"complete",
|
|
307
|
-
"confirmed",
|
|
308
|
-
createdAt,
|
|
309
|
-
{ messageCount: 1 },
|
|
310
|
-
),
|
|
311
|
-
);
|
|
312
|
-
return { status: "confirmed", channel: this.id, attemptId };
|
|
313
|
-
} finally {
|
|
314
|
-
if (this.promptAbort === controller) this.promptAbort = null;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
private async alreadyDecided(decisionId: string): Promise<boolean> {
|
|
319
|
-
return (
|
|
320
|
-
(await this.options.store.readResolved(decisionId)) !== null ||
|
|
321
|
-
(await this.options.store.readCancellation(decisionId)) !== null
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async settle(decision: SettledHumanDecision): Promise<void> {
|
|
326
|
-
this.promptAbort?.abort();
|
|
327
|
-
if (this.settlementTask !== null) return await this.settlementTask;
|
|
328
|
-
const task = this.settleOnce(decision);
|
|
329
|
-
this.settlementTask = task;
|
|
330
|
-
try {
|
|
331
|
-
await task;
|
|
332
|
-
} finally {
|
|
333
|
-
if (this.settlementTask === task) this.settlementTask = null;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private async settleOnce(decision: SettledHumanDecision): Promise<void> {
|
|
338
|
-
const prior = await this.options.store.listSettlements(decision.decisionId, this.id);
|
|
339
|
-
if (prior.some((record) => record.state === "confirmed")) return;
|
|
340
|
-
const createdAt = new Date().toISOString();
|
|
341
|
-
const record: HumanDecisionSettlementRecord = {
|
|
342
|
-
schema: "pi-workflows.human-decision-settlement.v1",
|
|
343
|
-
attemptId: createHumanDecisionAttemptId(),
|
|
344
|
-
decisionId: decision.decisionId,
|
|
345
|
-
requestDigest: decision.requestDigest,
|
|
346
|
-
channel: this.id,
|
|
347
|
-
state: "confirmed",
|
|
348
|
-
createdAt,
|
|
349
|
-
finishedAt: createdAt,
|
|
350
|
-
};
|
|
351
|
-
await this.options.store.recordSettlement(decision.decisionId, this.id, record);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
type TelegramProfile = {
|
|
356
|
-
name: string;
|
|
357
|
-
token: string;
|
|
358
|
-
allowedUserIds: Set<string>;
|
|
359
|
-
allowedChatIds: Set<string>;
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
type CallbackBinding = {
|
|
363
|
-
request: HumanDecisionChannelRequest;
|
|
364
|
-
choice: string;
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
type ReplyBinding = CallbackBinding & {
|
|
368
|
-
chatId: string;
|
|
369
|
-
promptMessageId: string;
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
type MessageRow = { chat_id: string; message_id: string };
|
|
373
|
-
type MessagePartRow = {
|
|
374
|
-
recipient_index: number;
|
|
375
|
-
part_index: number;
|
|
376
|
-
content_digest: string;
|
|
377
|
-
chat_id: string;
|
|
378
|
-
message_id: string;
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
class TelegramProjection {
|
|
382
|
-
private readonly channelId: string;
|
|
383
|
-
private readonly resourceId: string;
|
|
384
|
-
private readonly token = randomBytes(32).toString("base64url");
|
|
385
|
-
private generation = 0;
|
|
386
|
-
|
|
387
|
-
constructor(
|
|
388
|
-
private readonly state: StateDatabase,
|
|
389
|
-
private readonly profile: string,
|
|
390
|
-
private readonly owner: string,
|
|
391
|
-
) {
|
|
392
|
-
this.channelId = `telegram-${createHash("sha256").update(profile).digest("hex").slice(0, 40)}`;
|
|
393
|
-
this.resourceId = resourceIdFor("channel", this.channelId);
|
|
394
|
-
this.state.transaction(() => {
|
|
395
|
-
const now = Date.now();
|
|
396
|
-
this.state.connection
|
|
397
|
-
.prepare(
|
|
398
|
-
`INSERT INTO resources(
|
|
399
|
-
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
400
|
-
) VALUES (?, 'channel', ?, 1, ?, ?)
|
|
401
|
-
ON CONFLICT(resource_type, aggregate_key) DO NOTHING`,
|
|
402
|
-
)
|
|
403
|
-
.run(this.resourceId, this.channelId, now, now);
|
|
404
|
-
this.state.connection
|
|
405
|
-
.prepare(
|
|
406
|
-
"INSERT INTO leases(resource_id, generation) VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING",
|
|
407
|
-
)
|
|
408
|
-
.run(this.resourceId);
|
|
409
|
-
this.state.connection
|
|
410
|
-
.prepare(
|
|
411
|
-
`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
|
|
412
|
-
VALUES (?, ?, 'telegram', ?, ?) ON CONFLICT(channel_id) DO NOTHING`,
|
|
413
|
-
)
|
|
414
|
-
.run(this.channelId, this.resourceId, this.profile, now);
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
close(): void {
|
|
419
|
-
this.release();
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
ownsLease(): boolean {
|
|
423
|
-
try {
|
|
424
|
-
this.assertOwner();
|
|
425
|
-
return true;
|
|
426
|
-
} catch {
|
|
427
|
-
return false;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
acquire(now = Date.now()): boolean {
|
|
432
|
-
const observed = this.lease();
|
|
433
|
-
const observedToken = tokenHash(this.token);
|
|
434
|
-
const ownedByThis =
|
|
435
|
-
observed.ownerId === this.owner &&
|
|
436
|
-
observed.tokenHash !== null &&
|
|
437
|
-
observed.tokenHash.equals(observedToken) &&
|
|
438
|
-
observed.expiresAt !== null &&
|
|
439
|
-
observed.expiresAt > now;
|
|
440
|
-
if (ownedByThis) {
|
|
441
|
-
this.generation = observed.generation;
|
|
442
|
-
return true;
|
|
443
|
-
}
|
|
444
|
-
if (observed.ownerId !== null && observed.expiresAt !== null && observed.expiresAt > now) {
|
|
445
|
-
return false;
|
|
446
|
-
}
|
|
447
|
-
return this.state.transaction(() => {
|
|
448
|
-
const lease = this.lease();
|
|
449
|
-
if (
|
|
450
|
-
lease.ownerId !== null &&
|
|
451
|
-
lease.ownerId !== this.owner &&
|
|
452
|
-
lease.expiresAt !== null &&
|
|
453
|
-
lease.expiresAt > now
|
|
454
|
-
) {
|
|
455
|
-
return false;
|
|
456
|
-
}
|
|
457
|
-
const sameToken =
|
|
458
|
-
lease.ownerId === this.owner &&
|
|
459
|
-
lease.tokenHash !== null &&
|
|
460
|
-
lease.tokenHash.equals(observedToken);
|
|
461
|
-
const generation = sameToken ? lease.generation : lease.generation + 1;
|
|
462
|
-
const result = this.state.connection
|
|
463
|
-
.prepare(
|
|
464
|
-
`UPDATE leases
|
|
465
|
-
SET generation = ?, owner_type = 'channel', owner_id = ?, token_hash = ?,
|
|
466
|
-
acquired_at = COALESCE(acquired_at, ?), heartbeat_at = ?, expires_at = ?
|
|
467
|
-
WHERE resource_id = ? AND generation = ?`,
|
|
468
|
-
)
|
|
469
|
-
.run(
|
|
470
|
-
generation,
|
|
471
|
-
this.owner,
|
|
472
|
-
observedToken,
|
|
473
|
-
now,
|
|
474
|
-
now,
|
|
475
|
-
now + LEASE_TTL_MS,
|
|
476
|
-
this.resourceId,
|
|
477
|
-
lease.generation,
|
|
478
|
-
);
|
|
479
|
-
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
480
|
-
if (result.changes !== 1) return false;
|
|
481
|
-
this.generation = generation;
|
|
482
|
-
this.recordMutation("channel.lease_acquired", { expiresAt: now + LEASE_TTL_MS }, now);
|
|
483
|
-
return true;
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
renew(now = Date.now()): boolean {
|
|
488
|
-
const lease = this.lease();
|
|
489
|
-
if (
|
|
490
|
-
lease.ownerId !== this.owner ||
|
|
491
|
-
lease.generation !== this.generation ||
|
|
492
|
-
lease.tokenHash === null ||
|
|
493
|
-
!lease.tokenHash.equals(tokenHash(this.token)) ||
|
|
494
|
-
lease.expiresAt === null ||
|
|
495
|
-
lease.expiresAt <= now
|
|
496
|
-
) {
|
|
497
|
-
return false;
|
|
498
|
-
}
|
|
499
|
-
if (lease.expiresAt - now > LEASE_RENEW_WINDOW_MS) return true;
|
|
500
|
-
return (
|
|
501
|
-
this.state.connection
|
|
502
|
-
.prepare(
|
|
503
|
-
`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
504
|
-
WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
|
|
505
|
-
)
|
|
506
|
-
.run(
|
|
507
|
-
now,
|
|
508
|
-
now + LEASE_TTL_MS,
|
|
509
|
-
this.resourceId,
|
|
510
|
-
this.owner,
|
|
511
|
-
tokenHash(this.token),
|
|
512
|
-
this.generation,
|
|
513
|
-
).changes === 1
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
release(): void {
|
|
518
|
-
this.state.transaction(() => {
|
|
519
|
-
const result = this.state.connection
|
|
520
|
-
.prepare(
|
|
521
|
-
`UPDATE leases
|
|
522
|
-
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
523
|
-
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
524
|
-
WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
|
|
525
|
-
)
|
|
526
|
-
.run(this.resourceId, this.owner, tokenHash(this.token), this.generation);
|
|
527
|
-
if (result.changes === 1) {
|
|
528
|
-
this.recordMutation("channel.lease_released", {}, Date.now());
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
offset(): number {
|
|
534
|
-
const row = this.state.connection
|
|
535
|
-
.prepare(
|
|
536
|
-
"SELECT cursor_value AS cursorValue FROM channel_cursors WHERE channel_id = ? AND cursor_key = 'telegram_update'",
|
|
537
|
-
)
|
|
538
|
-
.get(this.channelId);
|
|
539
|
-
return isCursorRow(row) ? Number.parseInt(row.cursorValue, 10) || 0 : 0;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
setOffset(offset: number): void {
|
|
543
|
-
this.mutate("channel.cursor_updated", { offset }, () => {
|
|
544
|
-
this.state.connection
|
|
545
|
-
.prepare(
|
|
546
|
-
`INSERT INTO channel_cursors(channel_id, cursor_key, cursor_value, updated_at)
|
|
547
|
-
VALUES (?, 'telegram_update', ?, ?)
|
|
548
|
-
ON CONFLICT(channel_id, cursor_key) DO UPDATE SET
|
|
549
|
-
cursor_value = CAST(MAX(CAST(channel_cursors.cursor_value AS INTEGER), CAST(excluded.cursor_value AS INTEGER)) AS TEXT),
|
|
550
|
-
updated_at = excluded.updated_at`,
|
|
551
|
-
)
|
|
552
|
-
.run(this.channelId, String(offset), Date.now());
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
putCallback(token: string, binding: CallbackBinding): void {
|
|
557
|
-
this.putInbox(`callback:${token}`, "callback", binding);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
callback(token: string): CallbackBinding | undefined {
|
|
561
|
-
return this.inbox<CallbackBinding>(`callback:${token}`);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
putReply(binding: ReplyBinding): void {
|
|
565
|
-
this.putInbox(`reply:${binding.chatId}:${binding.promptMessageId}`, "reply", binding);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
reply(chatId: string, messageId: string): ReplyBinding | undefined {
|
|
569
|
-
return this.inbox<ReplyBinding>(`reply:${chatId}:${messageId}`);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
deleteReply(chatId: string, messageId: string): void {
|
|
573
|
-
this.mutate("channel.reply_consumed", { chatId, messageId }, () => {
|
|
574
|
-
this.state.connection
|
|
575
|
-
.prepare("DELETE FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
|
|
576
|
-
.run(this.channelId, `reply:${chatId}:${messageId}`);
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
putMessage(decisionId: string, chatId: string, messageId: string): void {
|
|
581
|
-
const localId = transportMessageId(this.channelId, decisionId, chatId, messageId);
|
|
582
|
-
this.mutate("channel.message_confirmed", { decisionId, chatId, messageId }, () => {
|
|
583
|
-
const contentHash = this.state.putJson({ chatId, messageId });
|
|
584
|
-
this.state.connection
|
|
585
|
-
.prepare(
|
|
586
|
-
`INSERT INTO channel_messages(
|
|
587
|
-
message_id, channel_id, decision_id, purpose, content_hash,
|
|
588
|
-
external_conversation_ref, external_message_ref, status, created_at, updated_at
|
|
589
|
-
) VALUES (?, ?, ?, 'delivery', ?, ?, ?, 'confirmed', ?, ?)
|
|
590
|
-
ON CONFLICT(message_id) DO NOTHING`,
|
|
591
|
-
)
|
|
592
|
-
.run(
|
|
593
|
-
localId,
|
|
594
|
-
this.channelId,
|
|
595
|
-
decisionId,
|
|
596
|
-
contentHash,
|
|
597
|
-
chatId,
|
|
598
|
-
messageId,
|
|
599
|
-
Date.now(),
|
|
600
|
-
Date.now(),
|
|
601
|
-
);
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
messages(decisionId: string): MessageRow[] {
|
|
606
|
-
const rows = this.state.connection
|
|
607
|
-
.prepare(
|
|
608
|
-
`SELECT external_conversation_ref AS chatId, external_message_ref AS messageId
|
|
609
|
-
FROM channel_messages
|
|
610
|
-
WHERE channel_id = ? AND decision_id = ? AND purpose = 'delivery'
|
|
611
|
-
AND external_conversation_ref IS NOT NULL AND external_message_ref IS NOT NULL
|
|
612
|
-
ORDER BY external_conversation_ref, external_message_ref`,
|
|
613
|
-
)
|
|
614
|
-
.all(this.channelId, decisionId);
|
|
615
|
-
return rows.filter(isTransportMessageRow).map((row) => ({
|
|
616
|
-
chat_id: row.chatId,
|
|
617
|
-
message_id: row.messageId,
|
|
618
|
-
}));
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
putMessagePart(
|
|
622
|
-
decisionId: string,
|
|
623
|
-
recipientIndex: number,
|
|
624
|
-
partIndex: number,
|
|
625
|
-
contentDigest: string,
|
|
626
|
-
chatId: string,
|
|
627
|
-
messageId: string,
|
|
628
|
-
): void {
|
|
629
|
-
const localId = `telegram-parts-${createHash("sha256")
|
|
630
|
-
.update(`${this.channelId}\0${decisionId}`)
|
|
631
|
-
.digest("hex")
|
|
632
|
-
.slice(0, 40)}`;
|
|
633
|
-
this.mutate("channel.message_part_confirmed", { decisionId, recipientIndex, partIndex }, () => {
|
|
634
|
-
const parentHash = this.state.putJson({ decisionId });
|
|
635
|
-
this.state.connection
|
|
636
|
-
.prepare(
|
|
637
|
-
`INSERT INTO channel_messages(
|
|
638
|
-
message_id, channel_id, decision_id, purpose, content_hash,
|
|
639
|
-
status, created_at, updated_at
|
|
640
|
-
) VALUES (?, ?, ?, 'delivery', ?, 'confirmed', ?, ?)
|
|
641
|
-
ON CONFLICT(message_id) DO NOTHING`,
|
|
642
|
-
)
|
|
643
|
-
.run(localId, this.channelId, decisionId, parentHash, Date.now(), Date.now());
|
|
644
|
-
const contentHash = this.state.putText(contentDigest);
|
|
645
|
-
this.state.connection
|
|
646
|
-
.prepare(
|
|
647
|
-
`INSERT INTO channel_message_parts(
|
|
648
|
-
message_id, recipient_index, part_index, content_hash,
|
|
649
|
-
external_conversation_ref, external_message_ref
|
|
650
|
-
) VALUES (?, ?, ?, ?, ?, ?)
|
|
651
|
-
ON CONFLICT(message_id, recipient_index, part_index) DO UPDATE SET
|
|
652
|
-
content_hash = excluded.content_hash,
|
|
653
|
-
external_conversation_ref = excluded.external_conversation_ref,
|
|
654
|
-
external_message_ref = excluded.external_message_ref`,
|
|
655
|
-
)
|
|
656
|
-
.run(localId, recipientIndex, partIndex, contentHash, chatId, messageId);
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
messagePart(
|
|
661
|
-
decisionId: string,
|
|
662
|
-
recipientIndex: number,
|
|
663
|
-
partIndex: number,
|
|
664
|
-
): MessagePartRow | undefined {
|
|
665
|
-
const localId = `telegram-parts-${createHash("sha256")
|
|
666
|
-
.update(`${this.channelId}\0${decisionId}`)
|
|
667
|
-
.digest("hex")
|
|
668
|
-
.slice(0, 40)}`;
|
|
669
|
-
const row = this.state.connection
|
|
670
|
-
.prepare(
|
|
671
|
-
`SELECT recipient_index AS recipientIndex, part_index AS partIndex,
|
|
672
|
-
content_hash AS contentHash,
|
|
673
|
-
external_conversation_ref AS chatId,
|
|
674
|
-
external_message_ref AS messageId
|
|
675
|
-
FROM channel_message_parts
|
|
676
|
-
WHERE message_id = ? AND recipient_index = ? AND part_index = ?`,
|
|
677
|
-
)
|
|
678
|
-
.get(localId, recipientIndex, partIndex);
|
|
679
|
-
if (!isMessagePartProjectionRow(row)) return undefined;
|
|
680
|
-
const content = this.state.readBlob(row.contentHash);
|
|
681
|
-
/* istanbul ignore if -- foreign key guarantees the content blob */
|
|
682
|
-
if (content === undefined) throw new Error("Telegram part content is missing");
|
|
683
|
-
return {
|
|
684
|
-
recipient_index: row.recipientIndex,
|
|
685
|
-
part_index: row.partIndex,
|
|
686
|
-
content_digest: content.content.toString("utf8"),
|
|
687
|
-
chat_id: row.chatId,
|
|
688
|
-
message_id: row.messageId,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
private putInbox(
|
|
693
|
-
eventId: string,
|
|
694
|
-
type: "callback" | "reply",
|
|
695
|
-
binding: CallbackBinding | ReplyBinding,
|
|
696
|
-
): void {
|
|
697
|
-
this.mutate(`channel.${type}_stored`, { eventId }, () => {
|
|
698
|
-
const payloadHash = this.state.putJson(binding);
|
|
699
|
-
this.state.connection
|
|
700
|
-
.prepare(
|
|
701
|
-
`INSERT INTO channel_inbox(
|
|
702
|
-
channel_id, external_event_id, event_type, payload_hash, received_at
|
|
703
|
-
) VALUES (?, ?, ?, ?, ?)
|
|
704
|
-
ON CONFLICT(channel_id, external_event_id) DO UPDATE SET payload_hash = excluded.payload_hash`,
|
|
705
|
-
)
|
|
706
|
-
.run(this.channelId, eventId, type, payloadHash, Date.now());
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
private inbox<T>(eventId: string): T | undefined {
|
|
711
|
-
const row = this.state.connection
|
|
712
|
-
.prepare(
|
|
713
|
-
"SELECT payload_hash AS payloadHash FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?",
|
|
714
|
-
)
|
|
715
|
-
.get(this.channelId, eventId);
|
|
716
|
-
return isPayloadHashRow(row) ? (this.state.readJson(row.payloadHash) as T) : undefined;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
private mutate(type: string, payload: unknown, operation: () => void): void {
|
|
720
|
-
this.state.transaction(() => {
|
|
721
|
-
this.assertOwner();
|
|
722
|
-
operation();
|
|
723
|
-
this.recordMutation(type, payload, Date.now());
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
private recordMutation(type: string, payload: unknown, now: number): void {
|
|
728
|
-
const revisionRow = this.state.connection
|
|
729
|
-
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
730
|
-
.get(this.resourceId);
|
|
731
|
-
/* istanbul ignore if -- exact schema and internal query shape */
|
|
732
|
-
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
733
|
-
if (!isRevisionProjectionRow(revisionRow))
|
|
734
|
-
throw new Error("Telegram channel resource is missing");
|
|
735
|
-
const revision = revisionRow.revision + 1;
|
|
736
|
-
this.state.connection
|
|
737
|
-
.prepare(
|
|
738
|
-
"UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?",
|
|
739
|
-
)
|
|
740
|
-
.run(revision, now, this.resourceId, revision - 1);
|
|
741
|
-
const payloadHash = this.state.putJson(payload, now);
|
|
742
|
-
this.state.connection
|
|
743
|
-
.prepare(
|
|
744
|
-
`INSERT INTO events(
|
|
745
|
-
event_id, resource_id, resource_revision, event_type, actor_type,
|
|
746
|
-
actor_id, lease_generation, payload_hash, recorded_at
|
|
747
|
-
) VALUES (?, ?, ?, ?, 'channel', ?, ?, ?, ?)`,
|
|
748
|
-
)
|
|
749
|
-
.run(
|
|
750
|
-
`event-${randomUUID()}`,
|
|
751
|
-
this.resourceId,
|
|
752
|
-
revision,
|
|
753
|
-
type,
|
|
754
|
-
this.owner,
|
|
755
|
-
this.generation === 0 ? null : this.generation,
|
|
756
|
-
payloadHash,
|
|
757
|
-
now,
|
|
758
|
-
);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
private assertOwner(): void {
|
|
762
|
-
const lease = this.lease();
|
|
763
|
-
if (
|
|
764
|
-
lease.ownerId !== this.owner ||
|
|
765
|
-
lease.generation !== this.generation ||
|
|
766
|
-
lease.tokenHash === null ||
|
|
767
|
-
!lease.tokenHash.equals(tokenHash(this.token)) ||
|
|
768
|
-
lease.expiresAt === null ||
|
|
769
|
-
lease.expiresAt <= Date.now()
|
|
770
|
-
) {
|
|
771
|
-
throw new Error(`Telegram profile ${this.profile} is not owned by this channel process`);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
private lease(): TelegramLeaseRow {
|
|
776
|
-
const row = this.state.connection
|
|
777
|
-
.prepare(
|
|
778
|
-
`SELECT generation, owner_id AS ownerId, token_hash AS tokenHash,
|
|
779
|
-
expires_at AS expiresAt
|
|
780
|
-
FROM leases WHERE resource_id = ?`,
|
|
781
|
-
)
|
|
782
|
-
.get(this.resourceId);
|
|
783
|
-
/* istanbul ignore if -- exact schema and internal query shape */
|
|
784
|
-
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
785
|
-
if (!isTelegramLeaseRow(row)) throw new Error("Telegram channel lease is missing");
|
|
786
|
-
return row;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function transportMessageId(
|
|
791
|
-
channelId: string,
|
|
792
|
-
decisionId: string,
|
|
793
|
-
chatId: string,
|
|
794
|
-
messageId: string,
|
|
795
|
-
): string {
|
|
796
|
-
return `telegram-message-${createHash("sha256")
|
|
797
|
-
.update(`${channelId}\0${decisionId}\0${chatId}\0${messageId}`)
|
|
798
|
-
.digest("hex")
|
|
799
|
-
.slice(0, 40)}`;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
type TelegramLeaseRow = {
|
|
803
|
-
generation: number;
|
|
804
|
-
ownerId: string | null;
|
|
805
|
-
tokenHash: Buffer | null;
|
|
806
|
-
expiresAt: number | null;
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
function isTelegramLeaseRow(value: unknown): value is TelegramLeaseRow {
|
|
810
|
-
return isProjectionRecord(value);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
function isCursorRow(value: unknown): value is { cursorValue: string } {
|
|
814
|
-
return isProjectionRecord(value);
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
function isPayloadHashRow(value: unknown): value is { payloadHash: Buffer } {
|
|
818
|
-
return isProjectionRecord(value);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function isTransportMessageRow(value: unknown): value is { chatId: string; messageId: string } {
|
|
822
|
-
return isProjectionRecord(value);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function isMessagePartProjectionRow(value: unknown): value is {
|
|
826
|
-
recipientIndex: number;
|
|
827
|
-
partIndex: number;
|
|
828
|
-
contentHash: Buffer;
|
|
829
|
-
chatId: string;
|
|
830
|
-
messageId: string;
|
|
831
|
-
} {
|
|
832
|
-
return isProjectionRecord(value);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function isRevisionProjectionRow(value: unknown): value is { revision: number } {
|
|
836
|
-
return isProjectionRecord(value);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function isProjectionRecord(value: unknown): value is Record<string, unknown> {
|
|
840
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
class TelegramCallError extends Error {
|
|
844
|
-
constructor(
|
|
845
|
-
message: string,
|
|
846
|
-
readonly ambiguous: boolean,
|
|
847
|
-
readonly code: string,
|
|
848
|
-
) {
|
|
849
|
-
super(message);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
854
|
-
readonly id: string;
|
|
855
|
-
private readonly profile: TelegramProfile;
|
|
856
|
-
private readonly store: HumanDecisionStore;
|
|
857
|
-
private readonly fetchFn: TelegramFetch;
|
|
858
|
-
private readonly apiBase: string;
|
|
859
|
-
private readonly onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
|
|
860
|
-
private readonly projection: TelegramProjection;
|
|
861
|
-
private running = false;
|
|
862
|
-
private pollAbort: AbortController | null = null;
|
|
863
|
-
private pollTask: Promise<void> | null = null;
|
|
864
|
-
private readonly settlementTasks = new Map<string, Promise<void>>();
|
|
865
|
-
|
|
866
|
-
constructor(options: {
|
|
867
|
-
profileName: string;
|
|
868
|
-
token: string;
|
|
869
|
-
allowedUserIds: string[];
|
|
870
|
-
allowedChatIds: string[];
|
|
871
|
-
store: HumanDecisionStore;
|
|
872
|
-
configDir?: string;
|
|
873
|
-
onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
|
|
874
|
-
fetchFn?: TelegramFetch;
|
|
875
|
-
apiBase?: string;
|
|
876
|
-
ownerId?: string;
|
|
877
|
-
}) {
|
|
878
|
-
requireSimpleId(options.profileName, "Telegram profile");
|
|
879
|
-
if (options.token.trim().length === 0) throw new Error("Telegram token must not be empty");
|
|
880
|
-
validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
|
|
881
|
-
validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
|
|
882
|
-
this.id = `telegram:${options.profileName}`;
|
|
883
|
-
this.profile = {
|
|
884
|
-
name: options.profileName,
|
|
885
|
-
token: options.token,
|
|
886
|
-
allowedUserIds: new Set(options.allowedUserIds),
|
|
887
|
-
allowedChatIds: new Set(options.allowedChatIds),
|
|
888
|
-
};
|
|
889
|
-
this.store = options.store;
|
|
890
|
-
this.onAnswer = options.onAnswer;
|
|
891
|
-
this.fetchFn = options.fetchFn ?? (fetch as TelegramFetch);
|
|
892
|
-
this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
893
|
-
this.projection = new TelegramProjection(
|
|
894
|
-
options.store.state,
|
|
895
|
-
options.profileName,
|
|
896
|
-
options.ownerId ?? randomUUID(),
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
async start(): Promise<void> {
|
|
901
|
-
if (this.running) return;
|
|
902
|
-
this.running = true;
|
|
903
|
-
this.pollAbort = new AbortController();
|
|
904
|
-
this.pollTask = this.poll(this.pollAbort.signal);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
async stop(): Promise<void> {
|
|
908
|
-
this.running = false;
|
|
909
|
-
this.pollAbort?.abort();
|
|
910
|
-
await this.pollTask?.catch(() => undefined);
|
|
911
|
-
await Promise.allSettled(this.settlementTasks.values());
|
|
912
|
-
this.pollAbort = null;
|
|
913
|
-
this.pollTask = null;
|
|
914
|
-
this.projection.close();
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
|
|
918
|
-
const channelPath = this.channelPath();
|
|
919
|
-
if (!this.projection.ownsLease()) {
|
|
920
|
-
const attemptId = `queued-${createHash("sha256")
|
|
921
|
-
.update(`${this.id}\0${request.decisionId}\0${request.requestDigest}`)
|
|
922
|
-
.digest("hex")
|
|
923
|
-
.slice(0, 40)}`;
|
|
924
|
-
await this.store.recordDelivery(
|
|
925
|
-
request,
|
|
926
|
-
channelPath,
|
|
927
|
-
deliveryRecord(
|
|
928
|
-
request,
|
|
929
|
-
this.id,
|
|
930
|
-
attemptId,
|
|
931
|
-
"complete",
|
|
932
|
-
"intent",
|
|
933
|
-
new Date().toISOString(),
|
|
934
|
-
{
|
|
935
|
-
errorCode: "queued_for_channel_owner",
|
|
936
|
-
},
|
|
937
|
-
),
|
|
938
|
-
);
|
|
939
|
-
return {
|
|
940
|
-
status: "unknown",
|
|
941
|
-
channel: this.id,
|
|
942
|
-
attemptId,
|
|
943
|
-
errorCode: "queued_for_channel_owner",
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
const parts = renderTelegramParts(request);
|
|
947
|
-
const partDigests = parts.map((part) => digestCanonical(part));
|
|
948
|
-
const prior = await this.store.listDeliveries(request.decisionId, channelPath);
|
|
949
|
-
if (prior.some(isCompleteDelivery)) {
|
|
950
|
-
this.registerCallbacks(request);
|
|
951
|
-
return { status: "confirmed", channel: this.id, attemptId: "adopted" };
|
|
952
|
-
}
|
|
953
|
-
if (prior.some((record) => record.state === "unknown")) {
|
|
954
|
-
this.registerCallbacks(request);
|
|
955
|
-
return {
|
|
956
|
-
status: "unknown",
|
|
957
|
-
channel: this.id,
|
|
958
|
-
attemptId: "adopted",
|
|
959
|
-
errorCode: "ambiguous_delivery_not_retried",
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
if (hasUnsettledDeliveryIntent(prior)) {
|
|
963
|
-
this.registerCallbacks(request);
|
|
964
|
-
return {
|
|
965
|
-
status: "unknown",
|
|
966
|
-
channel: this.id,
|
|
967
|
-
attemptId: "adopted",
|
|
968
|
-
errorCode: "unsettled_delivery_intent_not_retried",
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
const attemptId = createHumanDecisionAttemptId();
|
|
973
|
-
const createdAt = new Date().toISOString();
|
|
974
|
-
await this.store.recordDelivery(
|
|
975
|
-
request,
|
|
976
|
-
channelPath,
|
|
977
|
-
deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
|
|
978
|
-
partCount: parts.length,
|
|
979
|
-
}),
|
|
980
|
-
);
|
|
981
|
-
const keyboard = this.registerCallbacks(request);
|
|
982
|
-
const recipients = [...this.profile.allowedChatIds].sort((left, right) =>
|
|
983
|
-
left.localeCompare(right),
|
|
984
|
-
);
|
|
985
|
-
for (const [recipientOffset, chatId] of recipients.entries()) {
|
|
986
|
-
const recipientIndex = recipientOffset + 1;
|
|
987
|
-
for (const [partOffset, text] of parts.entries()) {
|
|
988
|
-
const partIndex = partOffset + 1;
|
|
989
|
-
const contentDigest = partDigests[partOffset]!;
|
|
990
|
-
const projected = this.projection.messagePart(
|
|
991
|
-
request.decisionId,
|
|
992
|
-
recipientIndex,
|
|
993
|
-
partIndex,
|
|
994
|
-
);
|
|
995
|
-
const confirmed = prior.some(
|
|
996
|
-
(record) =>
|
|
997
|
-
record.phase === "part" &&
|
|
998
|
-
record.state === "confirmed" &&
|
|
999
|
-
record.recipientIndex === recipientIndex &&
|
|
1000
|
-
record.partIndex === partIndex &&
|
|
1001
|
-
record.partCount === parts.length &&
|
|
1002
|
-
record.contentDigest === contentDigest,
|
|
1003
|
-
);
|
|
1004
|
-
if (
|
|
1005
|
-
confirmed &&
|
|
1006
|
-
projected?.content_digest === contentDigest &&
|
|
1007
|
-
projected.chat_id === chatId
|
|
1008
|
-
) {
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
if (confirmed || projected !== undefined) {
|
|
1012
|
-
const errorCode = "telegram_part_evidence_mismatch";
|
|
1013
|
-
await this.store.recordDelivery(
|
|
1014
|
-
request,
|
|
1015
|
-
channelPath,
|
|
1016
|
-
deliveryRecord(
|
|
1017
|
-
request,
|
|
1018
|
-
this.id,
|
|
1019
|
-
`${attemptId}-r${recipientIndex}-p${partIndex}-unknown`,
|
|
1020
|
-
"part",
|
|
1021
|
-
"unknown",
|
|
1022
|
-
createdAt,
|
|
1023
|
-
{ recipientIndex, partIndex, partCount: parts.length, contentDigest, errorCode },
|
|
1024
|
-
),
|
|
1025
|
-
);
|
|
1026
|
-
return { status: "unknown", channel: this.id, attemptId, errorCode };
|
|
1027
|
-
}
|
|
1028
|
-
const partAttemptId = `${attemptId}-r${recipientIndex}-p${partIndex}`;
|
|
1029
|
-
await this.store.recordDelivery(
|
|
1030
|
-
request,
|
|
1031
|
-
channelPath,
|
|
1032
|
-
deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
|
|
1033
|
-
recipientIndex,
|
|
1034
|
-
partIndex,
|
|
1035
|
-
partCount: parts.length,
|
|
1036
|
-
contentDigest,
|
|
1037
|
-
}),
|
|
1038
|
-
);
|
|
1039
|
-
try {
|
|
1040
|
-
const result = await this.call("sendMessage", {
|
|
1041
|
-
chat_id: chatId,
|
|
1042
|
-
text,
|
|
1043
|
-
...(partIndex === parts.length ? { reply_markup: { inline_keyboard: keyboard } } : {}),
|
|
1044
|
-
});
|
|
1045
|
-
const messageId = telegramMessageId(result);
|
|
1046
|
-
this.projection.putMessage(request.decisionId, chatId, messageId);
|
|
1047
|
-
this.projection.putMessagePart(
|
|
1048
|
-
request.decisionId,
|
|
1049
|
-
recipientIndex,
|
|
1050
|
-
partIndex,
|
|
1051
|
-
contentDigest,
|
|
1052
|
-
chatId,
|
|
1053
|
-
messageId,
|
|
1054
|
-
);
|
|
1055
|
-
await this.store.recordDelivery(
|
|
1056
|
-
request,
|
|
1057
|
-
channelPath,
|
|
1058
|
-
deliveryRecord(
|
|
1059
|
-
request,
|
|
1060
|
-
this.id,
|
|
1061
|
-
`${partAttemptId}-confirmed`,
|
|
1062
|
-
"part",
|
|
1063
|
-
"confirmed",
|
|
1064
|
-
createdAt,
|
|
1065
|
-
{ recipientIndex, partIndex, partCount: parts.length, contentDigest },
|
|
1066
|
-
),
|
|
1067
|
-
);
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
const callError = normalizeCallError(error);
|
|
1070
|
-
const state = callError.ambiguous ? "unknown" : "failed";
|
|
1071
|
-
await this.store.recordDelivery(
|
|
1072
|
-
request,
|
|
1073
|
-
channelPath,
|
|
1074
|
-
deliveryRecord(
|
|
1075
|
-
request,
|
|
1076
|
-
this.id,
|
|
1077
|
-
`${partAttemptId}-${state}`,
|
|
1078
|
-
"part",
|
|
1079
|
-
state,
|
|
1080
|
-
createdAt,
|
|
1081
|
-
{
|
|
1082
|
-
recipientIndex,
|
|
1083
|
-
partIndex,
|
|
1084
|
-
partCount: parts.length,
|
|
1085
|
-
contentDigest,
|
|
1086
|
-
errorCode: callError.code,
|
|
1087
|
-
},
|
|
1088
|
-
),
|
|
1089
|
-
);
|
|
1090
|
-
await this.store.recordDelivery(
|
|
1091
|
-
request,
|
|
1092
|
-
channelPath,
|
|
1093
|
-
deliveryRecord(
|
|
1094
|
-
request,
|
|
1095
|
-
this.id,
|
|
1096
|
-
`${attemptId}-${state}`,
|
|
1097
|
-
"complete",
|
|
1098
|
-
state,
|
|
1099
|
-
createdAt,
|
|
1100
|
-
{ partCount: parts.length, errorCode: callError.code },
|
|
1101
|
-
),
|
|
1102
|
-
);
|
|
1103
|
-
return { status: state, channel: this.id, attemptId, errorCode: callError.code };
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
await this.store.recordDelivery(
|
|
1108
|
-
request,
|
|
1109
|
-
channelPath,
|
|
1110
|
-
deliveryRecord(
|
|
1111
|
-
request,
|
|
1112
|
-
this.id,
|
|
1113
|
-
`${attemptId}-confirmed`,
|
|
1114
|
-
"complete",
|
|
1115
|
-
"confirmed",
|
|
1116
|
-
createdAt,
|
|
1117
|
-
{ partCount: parts.length, messageCount: parts.length * recipients.length },
|
|
1118
|
-
),
|
|
1119
|
-
);
|
|
1120
|
-
return { status: "confirmed", channel: this.id, attemptId };
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
async settle(decision: SettledHumanDecision): Promise<void> {
|
|
1124
|
-
const existing = this.settlementTasks.get(decision.decisionId);
|
|
1125
|
-
if (existing !== undefined) return await existing;
|
|
1126
|
-
const task = this.settleOnce(decision);
|
|
1127
|
-
this.settlementTasks.set(decision.decisionId, task);
|
|
1128
|
-
try {
|
|
1129
|
-
await task;
|
|
1130
|
-
} finally {
|
|
1131
|
-
if (this.settlementTasks.get(decision.decisionId) === task) {
|
|
1132
|
-
this.settlementTasks.delete(decision.decisionId);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
private async settleOnce(decision: SettledHumanDecision): Promise<void> {
|
|
1138
|
-
const channelPath = this.channelPath();
|
|
1139
|
-
const prior = await this.store.listSettlements(decision.decisionId, channelPath);
|
|
1140
|
-
if (
|
|
1141
|
-
prior.some((record) => record.state === "confirmed") ||
|
|
1142
|
-
prior.length >= MAX_SETTLEMENT_ATTEMPTS
|
|
1143
|
-
) {
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
const attemptId = createHumanDecisionAttemptId();
|
|
1147
|
-
const createdAt = new Date().toISOString();
|
|
1148
|
-
let failed = false;
|
|
1149
|
-
for (const message of this.projection.messages(decision.decisionId)) {
|
|
1150
|
-
try {
|
|
1151
|
-
await this.call("editMessageReplyMarkup", {
|
|
1152
|
-
chat_id: message.chat_id,
|
|
1153
|
-
message_id: Number(message.message_id),
|
|
1154
|
-
reply_markup: { inline_keyboard: [] },
|
|
1155
|
-
});
|
|
1156
|
-
} catch (error) {
|
|
1157
|
-
if (normalizeCallError(error).code !== "telegram_message_not_modified") failed = true;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
const record: HumanDecisionSettlementRecord = {
|
|
1161
|
-
schema: "pi-workflows.human-decision-settlement.v1",
|
|
1162
|
-
attemptId,
|
|
1163
|
-
decisionId: decision.decisionId,
|
|
1164
|
-
requestDigest: decision.requestDigest,
|
|
1165
|
-
channel: this.id,
|
|
1166
|
-
state: failed ? "failed" : "confirmed",
|
|
1167
|
-
createdAt,
|
|
1168
|
-
finishedAt: new Date().toISOString(),
|
|
1169
|
-
...(failed ? { errorCode: "telegram_settlement_failed" } : {}),
|
|
1170
|
-
};
|
|
1171
|
-
await this.store.recordSettlement(decision.decisionId, channelPath, record);
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
async handleUpdate(value: unknown): Promise<void> {
|
|
1175
|
-
const update = record(value, "Telegram update");
|
|
1176
|
-
const updateId = String(update.update_id ?? "");
|
|
1177
|
-
const callback = asRecord(update.callback_query);
|
|
1178
|
-
if (callback !== null) {
|
|
1179
|
-
const actorId = String(asRecord(callback.from)?.id ?? "");
|
|
1180
|
-
const message = asRecord(callback.message);
|
|
1181
|
-
const chatId = String(asRecord(message?.chat)?.id ?? "");
|
|
1182
|
-
if (!this.allowed(actorId, chatId)) return;
|
|
1183
|
-
const binding = this.projection.callback(String(callback.data ?? ""));
|
|
1184
|
-
if (binding === undefined || this.isStale(binding.request)) return;
|
|
1185
|
-
const definition = binding.request.choices[binding.choice];
|
|
1186
|
-
if (definition === undefined) return;
|
|
1187
|
-
if (definition.input === undefined) {
|
|
1188
|
-
await this.onAnswer({
|
|
1189
|
-
request: binding.request,
|
|
1190
|
-
response: { choice: binding.choice },
|
|
1191
|
-
source: { channel: this.id, actorId, eventId: String(callback.id ?? updateId) },
|
|
1192
|
-
idempotencyKey: `${this.id}:${String(callback.id ?? updateId)}`,
|
|
1193
|
-
});
|
|
1194
|
-
} else {
|
|
1195
|
-
const prompt = await this.call("sendMessage", {
|
|
1196
|
-
chat_id: chatId,
|
|
1197
|
-
text: definition.input.prompt,
|
|
1198
|
-
reply_markup: { force_reply: true, selective: true },
|
|
1199
|
-
});
|
|
1200
|
-
this.projection.putReply({
|
|
1201
|
-
...binding,
|
|
1202
|
-
chatId,
|
|
1203
|
-
promptMessageId: telegramMessageId(prompt),
|
|
1204
|
-
});
|
|
1205
|
-
}
|
|
1206
|
-
await this.call("answerCallbackQuery", { callback_query_id: callback.id }).catch(
|
|
1207
|
-
() => undefined,
|
|
1208
|
-
);
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
const message = asRecord(update.message);
|
|
1213
|
-
if (message === null) return;
|
|
1214
|
-
const actorId = String(asRecord(message.from)?.id ?? "");
|
|
1215
|
-
const chatId = String(asRecord(message.chat)?.id ?? "");
|
|
1216
|
-
if (!this.allowed(actorId, chatId)) return;
|
|
1217
|
-
const replyTo = asRecord(message.reply_to_message);
|
|
1218
|
-
const replyId = String(replyTo?.message_id ?? "");
|
|
1219
|
-
const binding = this.projection.reply(chatId, replyId);
|
|
1220
|
-
if (binding === undefined || this.isStale(binding.request)) return;
|
|
1221
|
-
const definition = binding.request.choices[binding.choice];
|
|
1222
|
-
const text = message.text;
|
|
1223
|
-
if (definition?.input === undefined || typeof text !== "string") return;
|
|
1224
|
-
await this.onAnswer({
|
|
1225
|
-
request: binding.request,
|
|
1226
|
-
response: { choice: binding.choice, input: { [definition.input.name]: text } },
|
|
1227
|
-
source: { channel: this.id, actorId, eventId: String(message.message_id ?? updateId) },
|
|
1228
|
-
idempotencyKey: `${this.id}:${String(message.message_id ?? updateId)}`,
|
|
1229
|
-
});
|
|
1230
|
-
this.projection.deleteReply(chatId, replyId);
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
private registerCallbacks(
|
|
1234
|
-
request: HumanDecisionChannelRequest,
|
|
1235
|
-
): Array<Array<Record<string, string>>> {
|
|
1236
|
-
return Object.entries(request.choices).map(([choiceId, definition]) => {
|
|
1237
|
-
const token = callbackToken(request.decisionId, request.requestDigest, choiceId);
|
|
1238
|
-
this.projection.putCallback(token, { request, choice: choiceId });
|
|
1239
|
-
return [{ text: definition.label, callback_data: token }];
|
|
1240
|
-
});
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
private channelPath(): string {
|
|
1244
|
-
return this.id.replace(":", "-");
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
private allowed(userId: string, chatId: string): boolean {
|
|
1248
|
-
return this.profile.allowedUserIds.has(userId) && this.profile.allowedChatIds.has(chatId);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
private isStale(request: HumanDecisionChannelRequest): boolean {
|
|
1252
|
-
return request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now();
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
private async poll(signal: AbortSignal): Promise<void> {
|
|
1256
|
-
while (this.running && !signal.aborted) {
|
|
1257
|
-
try {
|
|
1258
|
-
if (!this.projection.acquire()) {
|
|
1259
|
-
await wait(leaseRetryDelay(), signal);
|
|
1260
|
-
continue;
|
|
1261
|
-
}
|
|
1262
|
-
await this.deliverPendingRequests();
|
|
1263
|
-
const result = await this.call(
|
|
1264
|
-
"getUpdates",
|
|
1265
|
-
{
|
|
1266
|
-
offset: this.projection.offset(),
|
|
1267
|
-
timeout: 20,
|
|
1268
|
-
allowed_updates: ["callback_query", "message"],
|
|
1269
|
-
},
|
|
1270
|
-
signal,
|
|
1271
|
-
);
|
|
1272
|
-
const updates = Array.isArray(result) ? result : [];
|
|
1273
|
-
for (const update of updates) {
|
|
1274
|
-
await this.handleUpdate(update);
|
|
1275
|
-
const id = Number(asRecord(update)?.update_id);
|
|
1276
|
-
if (Number.isSafeInteger(id)) this.projection.setOffset(id + 1);
|
|
1277
|
-
}
|
|
1278
|
-
if (!this.projection.renew()) this.projection.release();
|
|
1279
|
-
} catch {
|
|
1280
|
-
if (signal.aborted) return;
|
|
1281
|
-
try {
|
|
1282
|
-
await wait(POLL_BACKOFF_MS, signal);
|
|
1283
|
-
} catch {
|
|
1284
|
-
if (signal.aborted) return;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
private async deliverPendingRequests(): Promise<void> {
|
|
1291
|
-
const channelPath = this.channelPath();
|
|
1292
|
-
for (const request of await this.store.listRequests()) {
|
|
1293
|
-
if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now()) continue;
|
|
1294
|
-
if ((await this.store.readResolved(request.decisionId)) !== null) continue;
|
|
1295
|
-
if ((await this.store.readCancellation(request.decisionId)) !== null) continue;
|
|
1296
|
-
const deliveries = await this.store.listDeliveries(request.decisionId, channelPath);
|
|
1297
|
-
if (
|
|
1298
|
-
!deliveries.some(
|
|
1299
|
-
(record) => record.state === "intent" && record.errorCode === "queued_for_channel_owner",
|
|
1300
|
-
)
|
|
1301
|
-
) {
|
|
1302
|
-
continue;
|
|
1303
|
-
}
|
|
1304
|
-
try {
|
|
1305
|
-
await this.deliver(humanDecisionChannelRequest(request));
|
|
1306
|
-
} catch {
|
|
1307
|
-
// Durable delivery evidence controls retries for each request.
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
private async call(method: string, payload: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
1313
|
-
let response: Awaited<ReturnType<TelegramFetch>>;
|
|
1314
|
-
try {
|
|
1315
|
-
response = await this.fetchFn(
|
|
1316
|
-
`${this.apiBase}/bot${encodeURIComponent(this.profile.token)}/${method}`,
|
|
1317
|
-
{
|
|
1318
|
-
method: "POST",
|
|
1319
|
-
headers: { "content-type": "application/json" },
|
|
1320
|
-
body: JSON.stringify(payload),
|
|
1321
|
-
...(signal !== undefined ? { signal } : {}),
|
|
1322
|
-
},
|
|
1323
|
-
);
|
|
1324
|
-
} catch {
|
|
1325
|
-
throw new TelegramCallError(
|
|
1326
|
-
`Telegram ${method} request did not return a response`,
|
|
1327
|
-
true,
|
|
1328
|
-
"telegram_response_unknown",
|
|
1329
|
-
);
|
|
1330
|
-
}
|
|
1331
|
-
const body = record(await response.json(), `Telegram ${method} response`);
|
|
1332
|
-
if (!response.ok) {
|
|
1333
|
-
const description = typeof body.description === "string" ? body.description : "";
|
|
1334
|
-
if (
|
|
1335
|
-
method === "editMessageReplyMarkup" &&
|
|
1336
|
-
description.toLowerCase().includes("message is not modified")
|
|
1337
|
-
) {
|
|
1338
|
-
throw new TelegramCallError(
|
|
1339
|
-
"Telegram message was already settled",
|
|
1340
|
-
false,
|
|
1341
|
-
"telegram_message_not_modified",
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
throw new TelegramCallError(
|
|
1345
|
-
`Telegram ${method} failed with HTTP ${response.status}`,
|
|
1346
|
-
false,
|
|
1347
|
-
`telegram_http_${response.status}`,
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
if (body.ok !== true) {
|
|
1351
|
-
throw new TelegramCallError(
|
|
1352
|
-
`Telegram ${method} rejected the request`,
|
|
1353
|
-
false,
|
|
1354
|
-
"telegram_rejected",
|
|
1355
|
-
);
|
|
1356
|
-
}
|
|
1357
|
-
return body.result;
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
export function createTelegramChannels(options: {
|
|
1362
|
-
config: DecisionChannelConfig;
|
|
1363
|
-
credentials: ResolvedDecisionCredentials;
|
|
1364
|
-
store: HumanDecisionStore;
|
|
1365
|
-
configDir?: string;
|
|
1366
|
-
onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
|
|
1367
|
-
fetchFn?: TelegramFetch;
|
|
1368
|
-
apiBase?: string;
|
|
1369
|
-
}): Map<string, TelegramDecisionChannel> {
|
|
1370
|
-
const channels = new Map<string, TelegramDecisionChannel>();
|
|
1371
|
-
for (const [name, profile] of Object.entries(options.config.telegramProfiles ?? {})) {
|
|
1372
|
-
const token = options.credentials[profile.credential];
|
|
1373
|
-
if (token === undefined) {
|
|
1374
|
-
throw new Error(`Telegram profile ${name} references a missing credential`);
|
|
1375
|
-
}
|
|
1376
|
-
const channel = new TelegramDecisionChannel({
|
|
1377
|
-
profileName: name,
|
|
1378
|
-
token,
|
|
1379
|
-
allowedUserIds: profile.allowedUserIds,
|
|
1380
|
-
allowedChatIds: profile.allowedChatIds,
|
|
1381
|
-
store: options.store,
|
|
1382
|
-
onAnswer: options.onAnswer,
|
|
1383
|
-
...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
|
|
1384
|
-
...(options.fetchFn !== undefined ? { fetchFn: options.fetchFn } : {}),
|
|
1385
|
-
...(options.apiBase !== undefined ? { apiBase: options.apiBase } : {}),
|
|
1386
|
-
});
|
|
1387
|
-
channels.set(channel.id, channel);
|
|
1388
|
-
}
|
|
1389
|
-
return channels;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
export async function verifyTelegramTokenFile(
|
|
1393
|
-
tokenFile: string,
|
|
1394
|
-
fetchFn?: TelegramFetch,
|
|
1395
|
-
apiBase = DEFAULT_API_BASE,
|
|
1396
|
-
): Promise<void> {
|
|
1397
|
-
/* istanbul ignore next -- global fetch is used only by operator setup */
|
|
1398
|
-
const request = fetchFn ?? (fetch as TelegramFetch);
|
|
1399
|
-
if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
|
|
1400
|
-
await requirePrivateFile(tokenFile, "Telegram token file");
|
|
1401
|
-
const token = (await fsp.readFile(tokenFile, "utf8")).trim();
|
|
1402
|
-
if (token.length === 0) throw new Error("Telegram token file is empty");
|
|
1403
|
-
let response;
|
|
1404
|
-
try {
|
|
1405
|
-
response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
|
|
1406
|
-
method: "POST",
|
|
1407
|
-
headers: { "content-type": "application/json" },
|
|
1408
|
-
body: "{}",
|
|
1409
|
-
});
|
|
1410
|
-
} catch {
|
|
1411
|
-
throw new Error("Telegram credential verification did not return a response");
|
|
1412
|
-
}
|
|
1413
|
-
if (!response.ok)
|
|
1414
|
-
throw new Error(`Telegram credential verification failed with HTTP ${response.status}`);
|
|
1415
|
-
const body = record(await response.json(), "Telegram credential verification response");
|
|
1416
|
-
if (body.ok !== true) throw new Error("Telegram credential verification was rejected");
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
export async function writeDecisionChannelProfile(options: {
|
|
1420
|
-
configDir?: string;
|
|
1421
|
-
audience: string;
|
|
1422
|
-
profile: string;
|
|
1423
|
-
credential: string;
|
|
1424
|
-
tokenFile: string;
|
|
1425
|
-
allowedUserIds: string[];
|
|
1426
|
-
allowedChatIds: string[];
|
|
1427
|
-
}): Promise<void> {
|
|
1428
|
-
const configDir = options.configDir ?? decisionConfigDir();
|
|
1429
|
-
requireSimpleId(options.audience, "Decision audience");
|
|
1430
|
-
requireSimpleId(options.profile, "Telegram profile");
|
|
1431
|
-
requireSimpleId(options.credential, "Telegram credential");
|
|
1432
|
-
validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
|
|
1433
|
-
validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
|
|
1434
|
-
if (!path.isAbsolute(options.tokenFile)) {
|
|
1435
|
-
throw new Error("Telegram token file path must be absolute");
|
|
1436
|
-
}
|
|
1437
|
-
await requirePrivateFile(options.tokenFile, "Telegram token file");
|
|
1438
|
-
await fsp.mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1439
|
-
const channelPath = path.join(configDir, "channels.json");
|
|
1440
|
-
const credentialPath = path.join(configDir, "credentials.json");
|
|
1441
|
-
const existingChannels = await readOptionalJson(channelPath);
|
|
1442
|
-
const existingCredentials = await readOptionalJson(credentialPath);
|
|
1443
|
-
const parsedChannels =
|
|
1444
|
-
existingChannels === null
|
|
1445
|
-
? { schema: "pi-workflows.channels.v1" as const, audiences: {} }
|
|
1446
|
-
: parseChannelConfig(existingChannels);
|
|
1447
|
-
const parsedCredentials =
|
|
1448
|
-
existingCredentials === null
|
|
1449
|
-
? { schema: "pi-workflows.credentials.v1" as const, telegram: {} }
|
|
1450
|
-
: parseCredentialConfig(existingCredentials);
|
|
1451
|
-
const channels: DecisionChannelConfig = {
|
|
1452
|
-
schema: "pi-workflows.channels.v1",
|
|
1453
|
-
audiences: {
|
|
1454
|
-
...parsedChannels.audiences,
|
|
1455
|
-
[options.audience]: {
|
|
1456
|
-
channels: ["pi", `telegram:${options.profile}`],
|
|
1457
|
-
accept: "first-valid-answer",
|
|
1458
|
-
},
|
|
1459
|
-
},
|
|
1460
|
-
telegramProfiles: {
|
|
1461
|
-
...parsedChannels.telegramProfiles,
|
|
1462
|
-
[options.profile]: {
|
|
1463
|
-
credential: options.credential,
|
|
1464
|
-
allowedUserIds: options.allowedUserIds,
|
|
1465
|
-
allowedChatIds: options.allowedChatIds,
|
|
1466
|
-
},
|
|
1467
|
-
},
|
|
1468
|
-
};
|
|
1469
|
-
const credentials: DecisionCredentialConfig = {
|
|
1470
|
-
schema: "pi-workflows.credentials.v1",
|
|
1471
|
-
telegram: {
|
|
1472
|
-
...parsedCredentials.telegram,
|
|
1473
|
-
[options.credential]: { tokenFile: options.tokenFile },
|
|
1474
|
-
},
|
|
1475
|
-
};
|
|
1476
|
-
await writePrivateJson(channelPath, channels);
|
|
1477
|
-
await writePrivateJson(credentialPath, credentials);
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
function parseChannelConfig(value: unknown): DecisionChannelConfig {
|
|
1481
|
-
const config = record(value, "pi-workflows channel config");
|
|
1482
|
-
if (config.schema !== "pi-workflows.channels.v1") {
|
|
1483
|
-
throw new Error("pi-workflows channel config schema is invalid");
|
|
1484
|
-
}
|
|
1485
|
-
const telegramProfiles: NonNullable<DecisionChannelConfig["telegramProfiles"]> = {};
|
|
1486
|
-
if (config.telegramProfiles !== undefined) {
|
|
1487
|
-
for (const [name, raw] of Object.entries(
|
|
1488
|
-
record(config.telegramProfiles, "Telegram profiles"),
|
|
1489
|
-
)) {
|
|
1490
|
-
requireSimpleId(name, "Telegram profile");
|
|
1491
|
-
const profile = record(raw, `Telegram profile ${name}`);
|
|
1492
|
-
const allowedUserIds = stringArray(
|
|
1493
|
-
profile.allowedUserIds,
|
|
1494
|
-
`Telegram profile ${name} user IDs`,
|
|
1495
|
-
);
|
|
1496
|
-
const allowedChatIds = stringArray(
|
|
1497
|
-
profile.allowedChatIds,
|
|
1498
|
-
`Telegram profile ${name} chat IDs`,
|
|
1499
|
-
);
|
|
1500
|
-
validateNumericIds(allowedUserIds, `Telegram profile ${name} user IDs`);
|
|
1501
|
-
validateNumericIds(allowedChatIds, `Telegram profile ${name} chat IDs`);
|
|
1502
|
-
telegramProfiles[name] = {
|
|
1503
|
-
credential: requireSimpleId(profile.credential, `Telegram profile ${name} credential`),
|
|
1504
|
-
allowedUserIds,
|
|
1505
|
-
allowedChatIds,
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
const audiencesRaw = record(config.audiences, "pi-workflows audiences");
|
|
1510
|
-
const audiences: DecisionChannelConfig["audiences"] = {};
|
|
1511
|
-
for (const [name, raw] of Object.entries(audiencesRaw)) {
|
|
1512
|
-
requireSimpleId(name, "Decision audience");
|
|
1513
|
-
const audience = record(raw, `Audience ${name}`);
|
|
1514
|
-
if (audience.accept !== "first-valid-answer") {
|
|
1515
|
-
throw new Error(`Audience ${name} accept policy must be first-valid-answer`);
|
|
1516
|
-
}
|
|
1517
|
-
const channels = stringArray(audience.channels, `Audience ${name} channels`);
|
|
1518
|
-
if (channels.length === 0) throw new Error(`Audience ${name} must have at least one channel`);
|
|
1519
|
-
for (const channel of channels) {
|
|
1520
|
-
if (channel !== "pi") {
|
|
1521
|
-
const match = /^telegram:([A-Za-z0-9][A-Za-z0-9._-]{0,199})$/.exec(channel);
|
|
1522
|
-
if (match === null || telegramProfiles[match[1]!] === undefined) {
|
|
1523
|
-
throw new Error(`Audience ${name} references an unknown channel`);
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
audiences[name] = { channels, accept: "first-valid-answer" };
|
|
1528
|
-
}
|
|
1529
|
-
return {
|
|
1530
|
-
schema: "pi-workflows.channels.v1",
|
|
1531
|
-
audiences,
|
|
1532
|
-
...(Object.keys(telegramProfiles).length > 0 ? { telegramProfiles } : {}),
|
|
1533
|
-
};
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
|
|
1537
|
-
const config = record(value, "pi-workflows credential config");
|
|
1538
|
-
if (config.schema !== "pi-workflows.credentials.v1") {
|
|
1539
|
-
throw new Error("pi-workflows credential config schema is invalid");
|
|
1540
|
-
}
|
|
1541
|
-
const telegram: DecisionCredentialConfig["telegram"] = {};
|
|
1542
|
-
for (const [name, raw] of Object.entries(record(config.telegram, "Telegram credentials"))) {
|
|
1543
|
-
requireSimpleId(name, "Telegram credential");
|
|
1544
|
-
const credential = record(raw, `Telegram credential ${name}`);
|
|
1545
|
-
telegram[name] = {
|
|
1546
|
-
tokenFile: requireString(credential.tokenFile, `Telegram credential ${name} tokenFile`),
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
return { schema: "pi-workflows.credentials.v1", telegram };
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
type DeliveryRecord = HumanDecisionDeliveryRecord;
|
|
1553
|
-
|
|
1554
|
-
type DeliveryRecordExtra = Partial<
|
|
1555
|
-
Pick<
|
|
1556
|
-
DeliveryRecord,
|
|
1557
|
-
"recipientIndex" | "partIndex" | "partCount" | "contentDigest" | "messageCount" | "errorCode"
|
|
1558
|
-
>
|
|
1559
|
-
>;
|
|
1560
|
-
|
|
1561
|
-
function deliveryRecord(
|
|
1562
|
-
request: HumanDecisionChannelRequest,
|
|
1563
|
-
channel: string,
|
|
1564
|
-
attemptId: string,
|
|
1565
|
-
phase: DeliveryRecord["phase"],
|
|
1566
|
-
state: DeliveryRecord["state"],
|
|
1567
|
-
createdAt: string,
|
|
1568
|
-
extra: DeliveryRecordExtra = {},
|
|
1569
|
-
): DeliveryRecord {
|
|
1570
|
-
return {
|
|
1571
|
-
schema: "pi-workflows.human-decision-delivery.v1",
|
|
1572
|
-
attemptId,
|
|
1573
|
-
decisionId: request.decisionId,
|
|
1574
|
-
requestDigest: request.requestDigest,
|
|
1575
|
-
presentationDigest: request.presentationDigest,
|
|
1576
|
-
channel,
|
|
1577
|
-
phase,
|
|
1578
|
-
state,
|
|
1579
|
-
createdAt,
|
|
1580
|
-
...(state === "intent" ? {} : { finishedAt: new Date().toISOString() }),
|
|
1581
|
-
...extra,
|
|
1582
|
-
};
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
function isCompleteDelivery(record: HumanDecisionDeliveryRecord): boolean {
|
|
1586
|
-
return record.state === "confirmed" && record.phase === "complete";
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
function hasUnsettledDeliveryIntent(records: HumanDecisionDeliveryRecord[]): boolean {
|
|
1590
|
-
return records.some((record) => {
|
|
1591
|
-
if (record.phase !== "part" || record.state !== "intent") return false;
|
|
1592
|
-
return !records.some(
|
|
1593
|
-
(other) =>
|
|
1594
|
-
other.phase === "part" &&
|
|
1595
|
-
other.state !== "intent" &&
|
|
1596
|
-
other.recipientIndex === record.recipientIndex &&
|
|
1597
|
-
other.partIndex === record.partIndex &&
|
|
1598
|
-
other.contentDigest === record.contentDigest,
|
|
1599
|
-
);
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
export function renderDecisionText(request: HumanDecisionChannelRequest): string {
|
|
1604
|
-
const content = decisionDocumentSegments(request)
|
|
1605
|
-
.map((segment) => operatorSafeText(segment.text))
|
|
1606
|
-
.join("\n\n");
|
|
1607
|
-
return `${content}\n\nDecision ${decisionPresentationFingerprint(request)}`;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
export function renderTelegramParts(
|
|
1611
|
-
request: HumanDecisionChannelRequest,
|
|
1612
|
-
textLimit = TELEGRAM_TEXT_LIMIT,
|
|
1613
|
-
): string[] {
|
|
1614
|
-
const fingerprint = decisionPresentationFingerprint(request);
|
|
1615
|
-
const maximumHeader = telegramPartHeader(
|
|
1616
|
-
MAX_PRESENTATION_TRANSPORT_PARTS,
|
|
1617
|
-
MAX_PRESENTATION_TRANSPORT_PARTS,
|
|
1618
|
-
fingerprint,
|
|
1619
|
-
);
|
|
1620
|
-
const capacity = textLimit - maximumHeader.length;
|
|
1621
|
-
if (capacity < 1)
|
|
1622
|
-
throw new Error("Telegram decision text limit is too small for its part header");
|
|
1623
|
-
const bodyParts = packDecisionSegments(
|
|
1624
|
-
decisionDocumentSegments(request).map((segment) => operatorSafeText(segment.text)),
|
|
1625
|
-
capacity,
|
|
1626
|
-
);
|
|
1627
|
-
if (bodyParts.length > MAX_PRESENTATION_TRANSPORT_PARTS) {
|
|
1628
|
-
throw new Error(
|
|
1629
|
-
`Decision presentation renders as ${bodyParts.length} Telegram parts; limit is ${MAX_PRESENTATION_TRANSPORT_PARTS}`,
|
|
1630
|
-
);
|
|
1631
|
-
}
|
|
1632
|
-
return bodyParts.map((body, index) => {
|
|
1633
|
-
const text = `${telegramPartHeader(index + 1, bodyParts.length, fingerprint)}${body}`;
|
|
1634
|
-
if (text.length > textLimit) {
|
|
1635
|
-
throw new Error(
|
|
1636
|
-
`Telegram decision part ${index + 1} exceeds the ${textLimit} character limit`,
|
|
1637
|
-
);
|
|
1638
|
-
}
|
|
1639
|
-
return text;
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
function renderPiPresentationLines(
|
|
1644
|
-
request: HumanDecisionChannelRequest,
|
|
1645
|
-
width: number,
|
|
1646
|
-
theme: Theme,
|
|
1647
|
-
): string[] {
|
|
1648
|
-
const lines: string[] = [];
|
|
1649
|
-
for (const segment of decisionDocumentSegments(request).filter(
|
|
1650
|
-
(candidate) => candidate.kind !== "choices",
|
|
1651
|
-
)) {
|
|
1652
|
-
const text = operatorSafeText(segment.text);
|
|
1653
|
-
const styled =
|
|
1654
|
-
segment.kind === "title"
|
|
1655
|
-
? theme.fg("accent", theme.bold(text))
|
|
1656
|
-
: segment.kind === "section"
|
|
1657
|
-
? theme.fg("text", theme.bold(text))
|
|
1658
|
-
: segment.kind === "preformatted"
|
|
1659
|
-
? theme.fg("muted", text)
|
|
1660
|
-
: theme.fg("text", text);
|
|
1661
|
-
for (const rawLine of styled.split("\n")) {
|
|
1662
|
-
lines.push(...wrapTextWithAnsi(rawLine.length === 0 ? " " : rawLine, Math.max(1, width)));
|
|
1663
|
-
}
|
|
1664
|
-
lines.push("");
|
|
1665
|
-
}
|
|
1666
|
-
lines.push(theme.fg("dim", `Decision ${decisionPresentationFingerprint(request)}`));
|
|
1667
|
-
return lines;
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
function telegramPartHeader(part: number, total: number, fingerprint: string): string {
|
|
1671
|
-
return `Part ${part}/${total}\nDecision ${fingerprint}\n\n`;
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
function packDecisionSegments(segments: string[], capacity: number): string[] {
|
|
1675
|
-
const parts: string[] = [];
|
|
1676
|
-
let current = "";
|
|
1677
|
-
const flush = () => {
|
|
1678
|
-
if (current.length === 0) return;
|
|
1679
|
-
parts.push(current);
|
|
1680
|
-
current = "";
|
|
1681
|
-
};
|
|
1682
|
-
for (const segment of segments) {
|
|
1683
|
-
const pieces = splitTransportText(segment, capacity);
|
|
1684
|
-
for (const piece of pieces) {
|
|
1685
|
-
const candidate = current.length === 0 ? piece : `${current}\n\n${piece}`;
|
|
1686
|
-
if (candidate.length <= capacity) {
|
|
1687
|
-
current = candidate;
|
|
1688
|
-
} else {
|
|
1689
|
-
flush();
|
|
1690
|
-
current = piece;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
flush();
|
|
1695
|
-
return parts.length === 0 ? ["Decision"] : parts;
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
function splitTransportText(value: string, capacity: number): string[] {
|
|
1699
|
-
const parts: string[] = [];
|
|
1700
|
-
let remaining = value;
|
|
1701
|
-
while (remaining.length > capacity) {
|
|
1702
|
-
let end = codePointBoundary(remaining, capacity);
|
|
1703
|
-
const newline = remaining.lastIndexOf("\n", end);
|
|
1704
|
-
if (newline > 0) end = newline;
|
|
1705
|
-
parts.push(remaining.slice(0, end));
|
|
1706
|
-
remaining = remaining.slice(end);
|
|
1707
|
-
}
|
|
1708
|
-
if (remaining.length > 0) parts.push(remaining);
|
|
1709
|
-
return parts;
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
function codePointBoundary(value: string, limit: number): number {
|
|
1713
|
-
let end = Math.min(limit, value.length);
|
|
1714
|
-
const last = value.charCodeAt(end - 1);
|
|
1715
|
-
if (last >= 0xd800 && last <= 0xdbff && end < value.length) end -= 1;
|
|
1716
|
-
return Math.max(1, end);
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
function operatorSafeText(value: string): string {
|
|
1720
|
-
return value
|
|
1721
|
-
.replaceAll("\r\n", "\n")
|
|
1722
|
-
.replaceAll("\r", "\n")
|
|
1723
|
-
.split("")
|
|
1724
|
-
.map((character) => {
|
|
1725
|
-
const code = character.charCodeAt(0);
|
|
1726
|
-
return code <= 8 || (code >= 11 && code <= 31) || code === 127 ? "�" : character;
|
|
1727
|
-
})
|
|
1728
|
-
.join("");
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
function callbackToken(decisionId: string, requestDigest: string, choice: string): string {
|
|
1732
|
-
return `piw:${createHash("sha256")
|
|
1733
|
-
.update(`${decisionId}\0${requestDigest}\0${choice}`)
|
|
1734
|
-
.digest("hex")
|
|
1735
|
-
.slice(0, 24)}`;
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
function telegramMessageId(value: unknown): string {
|
|
1739
|
-
const message = record(value, "Telegram message");
|
|
1740
|
-
const id = message.message_id;
|
|
1741
|
-
if (!Number.isSafeInteger(id)) throw new Error("Telegram message id is missing");
|
|
1742
|
-
return String(id);
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
function normalizeCallError(error: unknown): TelegramCallError {
|
|
1746
|
-
return error instanceof TelegramCallError
|
|
1747
|
-
? error
|
|
1748
|
-
: new TelegramCallError("Telegram request failed", true, "telegram_response_unknown");
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
function validateNumericIds(values: string[], label: string): void {
|
|
1752
|
-
if (values.length === 0 || values.some((value) => !NUMERIC_ID.test(value))) {
|
|
1753
|
-
throw new Error(`${label} must contain numeric IDs`);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
function requireSimpleId(value: unknown, label: string): string {
|
|
1758
|
-
const text = requireString(value, label);
|
|
1759
|
-
if (!SIMPLE_ID.test(text)) throw new Error(`${label} is invalid`);
|
|
1760
|
-
return text;
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function requireString(value: unknown, label: string): string {
|
|
1764
|
-
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1765
|
-
throw new Error(`${label} must be a non-empty string`);
|
|
1766
|
-
}
|
|
1767
|
-
return value.trim();
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
function stringArray(value: unknown, label: string): string[] {
|
|
1771
|
-
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
1772
|
-
throw new Error(`${label} must be an array of strings`);
|
|
1773
|
-
}
|
|
1774
|
-
return [...value] as string[];
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
function record(value: unknown, label: string): Record<string, unknown> {
|
|
1778
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1779
|
-
throw new Error(`${label} must be an object`);
|
|
1780
|
-
}
|
|
1781
|
-
return value as Record<string, unknown>;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
1785
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1786
|
-
? (value as Record<string, unknown>)
|
|
1787
|
-
: null;
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
async function requirePrivateFile(filePath: string, label: string): Promise<void> {
|
|
1791
|
-
const stat = await fsp.stat(filePath);
|
|
1792
|
-
if (!stat.isFile()) throw new Error(`${label} must be a file`);
|
|
1793
|
-
if ((stat.mode & 0o077) !== 0) throw new Error(`${label} permissions must be 0600`);
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
async function readOptionalJson(filePath: string): Promise<unknown | null> {
|
|
1797
|
-
try {
|
|
1798
|
-
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
1799
|
-
} catch (error) {
|
|
1800
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1801
|
-
throw error;
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
async function writePrivateJson(filePath: string, value: unknown): Promise<void> {
|
|
1806
|
-
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1807
|
-
await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
1808
|
-
await fsp.rename(temporary, filePath);
|
|
1809
|
-
await fsp.chmod(filePath, 0o600);
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
function leaseRetryDelay(): number {
|
|
1813
|
-
return LEASE_RETRY_MS + Math.floor(Math.random() * 1_000);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
async function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
1817
|
-
await new Promise<void>((resolve) => {
|
|
1818
|
-
const timer = setTimeout(resolve, milliseconds);
|
|
1819
|
-
const abort = () => {
|
|
1820
|
-
clearTimeout(timer);
|
|
1821
|
-
resolve();
|
|
1822
|
-
};
|
|
1823
|
-
if (signal.aborted) abort();
|
|
1824
|
-
else signal.addEventListener("abort", abort, { once: true });
|
|
1825
|
-
});
|
|
1826
|
-
}
|