@fastagent-sh/fastagent 0.12.0
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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bot channel: verify the webhook secret token → decide via `route(update)` → run the turn →
|
|
3
|
+
* stream the agent's reply back to the chat, ACK 200. Reply model A: the channel holds the bot token
|
|
4
|
+
* and posts the reply itself (chat UX), unlike the github channel's fire-and-forget. No SDK — inbound
|
|
5
|
+
* is a JSON POST, outbound is a `fetch` to the Bot API. The developer writes only `route` (policy); the
|
|
6
|
+
* channel owns transport + format + attachments.
|
|
7
|
+
*
|
|
8
|
+
* This file is the Telegram WIRING: ingress (secret/body cap/JSON) + the per-turn lifecycle + composition.
|
|
9
|
+
* Every other concern lives in its own module, each owning its invariants:
|
|
10
|
+
* - parse.ts pure message parsing: field extraction, prompt envelope, summon/route policy
|
|
11
|
+
* - invoke-turn.ts run one turn: assemble inputs (resolve attachments) + stream `agent.invoke`
|
|
12
|
+
* - turn-queue.ts in-memory per-session serial execution (FIFO; one turn at a time per session)
|
|
13
|
+
* - turn-store.ts durable turn intent (L1): pre-ACK persist, replay a crash-surviving turn
|
|
14
|
+
* - context-buffer.ts un-summoned group discussion, folded into the next answered turn
|
|
15
|
+
* - preview.ts the live-preview pump ("💭 Thinking…" → edits → final answer) + terminal writes
|
|
16
|
+
* - telegram-api.ts the single Bot API pipeline (timeouts, 429, ok-gating, HTML-aware split)
|
|
17
|
+
* - state.ts atomic state files under the channel-state home
|
|
18
|
+
*
|
|
19
|
+
* Threaded Mode (topics in private chats, a @BotFather toggle) is auto-adapted: an update carrying
|
|
20
|
+
* message_thread_id replies into that thread; without one the chat is linear. Same code, both modes.
|
|
21
|
+
*
|
|
22
|
+
* Authored against the public `@fastagent-sh/fastagent` surface only (the contract + the channel-authoring
|
|
23
|
+
* kit: readBodyCapped / text), so it is exactly what a third-party `fastagent-channel-*` package would write.
|
|
24
|
+
*/
|
|
25
|
+
import { timingSafeEqual } from "node:crypto";
|
|
26
|
+
import { isAbsolute, join } from "node:path";
|
|
27
|
+
import { log } from "../../log.js";
|
|
28
|
+
import { readBodyCapped } from "../body.js";
|
|
29
|
+
import { text } from "../respond.js";
|
|
30
|
+
import { invokeTurn } from "./invoke-turn.js";
|
|
31
|
+
import { collectAttachments, createContextBuffer } from "./context-buffer.js";
|
|
32
|
+
import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, } from "./parse.js";
|
|
33
|
+
import { defaultErrorMessage, streamReply } from "./preview.js";
|
|
34
|
+
import { ensureStateHome } from "./state.js";
|
|
35
|
+
import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
36
|
+
import { createTurnQueue } from "./turn-queue.js";
|
|
37
|
+
import { createTurnStore } from "./turn-store.js";
|
|
38
|
+
// Re-export the public surface authored elsewhere, so `@fastagent-sh/fastagent/telegram` keeps one entry point.
|
|
39
|
+
export { defaultTelegramRoute, telegramEnvelope };
|
|
40
|
+
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
41
|
+
* rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
|
|
42
|
+
* at dequeue, so a never-run turn queued behind a poison one keeps its full budget.
|
|
43
|
+
*
|
|
44
|
+
* Known limitation: `startAttempt` cannot tell a self-inflicted process crash from an external SIGTERM
|
|
45
|
+
* (no graceful drain), so a legitimately LONG turn interrupted by this many successive deploys is
|
|
46
|
+
* dropped ("please ask again") as if it were poison. Accepted: catching SIGTERM to spare it would
|
|
47
|
+
* reintroduce the drain the design refuses, and a turn outliving this many deploy cycles is an outlier
|
|
48
|
+
* — raise this constant if such turns are expected. */
|
|
49
|
+
const MAX_TURN_ATTEMPTS = 3;
|
|
50
|
+
/** Update body cap — Telegram updates are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
51
|
+
const MAX_UPDATE_BYTES = 1 << 20;
|
|
52
|
+
/** Constant-time compare so the secret-token check leaks no timing signal. */
|
|
53
|
+
function tokenMatches(header, secret) {
|
|
54
|
+
const a = Buffer.from(header);
|
|
55
|
+
const b = Buffer.from(secret);
|
|
56
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a Telegram bot channel: policy options in, a {@link ChannelModule} out. The framework (or an
|
|
60
|
+
* embedder) mounts it with the context — `telegramChannel(opts)` in `channels/telegram.ts` is the whole
|
|
61
|
+
* glue; `agent` and the state root arrive via ctx, never through user code. Mounts `POST /telegram`
|
|
62
|
+
* (the path `--tunnel` webhook registration expects). The adapter owns that route key; to serve the
|
|
63
|
+
* SAME instance at a different path (e.g. behind a rewriting proxy), re-key the returned module:
|
|
64
|
+
* `(ctx) => ({ "POST /bot": telegramChannel(opts)(ctx)["POST /telegram"]! })`.
|
|
65
|
+
* This re-routes ONE instance — it is not a way to run two telegram bots in one workspace: the state
|
|
66
|
+
* home is derived from the channel kind (`<stateRoot>/channels/telegram`), so a second instance would
|
|
67
|
+
* share the first's turn-store/context-buffer. One telegram instance per workspace (single-process).
|
|
68
|
+
*/
|
|
69
|
+
export function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl = "https://api.telegram.org", }) {
|
|
70
|
+
// Both are mandatory: an unset secret_token would accept forged updates (the endpoint is public);
|
|
71
|
+
// the bot token is required to send the reply. Fail at construction (startup), not silently.
|
|
72
|
+
if (!secretToken) {
|
|
73
|
+
throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
|
|
74
|
+
}
|
|
75
|
+
if (!botToken) {
|
|
76
|
+
throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
|
|
77
|
+
}
|
|
78
|
+
return ({ agent, stateRoot }) => {
|
|
79
|
+
const formatError = onError ?? defaultErrorMessage;
|
|
80
|
+
// One getMe at startup: the bot's @username (for the default route's group @mention summon, only when
|
|
81
|
+
// not supplied) and the group-privacy flag — privacy mode off is required to receive the un-summoned
|
|
82
|
+
// group messages that feed the context buffer, so warn if it is on.
|
|
83
|
+
let mentionName = botUsername;
|
|
84
|
+
void callApi(apiBaseUrl, botToken, "getMe", {}).then((me) => {
|
|
85
|
+
if (mentionName === undefined)
|
|
86
|
+
mentionName = me.username;
|
|
87
|
+
if (me.can_read_all_group_messages === false) {
|
|
88
|
+
log.warn("[telegram] privacy mode is on: the bot only sees @mentions / replies / commands, so group " +
|
|
89
|
+
"context (un-summoned messages) won't be captured. Disable it via @BotFather → /setprivacy.");
|
|
90
|
+
}
|
|
91
|
+
}, (e) => log.warn(`[telegram] getMe failed; @mention summon + privacy check skipped: ${String(e)}`));
|
|
92
|
+
// A bot token is "<bot_id>:<secret>" — the bot's own id is knowable synchronously, so reply-to-bot
|
|
93
|
+
// targeting is precise from the first update (no getMe race; getMe only resolves the @username).
|
|
94
|
+
// Every real token parses; one that doesn't (a mock/test token) degrades visibly: reply summon stays
|
|
95
|
+
// off (fail-closed in repliesToBot) until getMe supplies the username tier.
|
|
96
|
+
const tokenId = Number(botToken.split(":")[0]);
|
|
97
|
+
const botId = Number.isSafeInteger(tokenId) && tokenId > 0 ? tokenId : undefined;
|
|
98
|
+
if (botId === undefined) {
|
|
99
|
+
log.warn("[telegram] bot token has no parseable bot id — reply-to-bot summon disabled until getMe resolves");
|
|
100
|
+
}
|
|
101
|
+
const decide = route ?? ((update) => defaultTelegramRoute(update, { botUsername: mentionName, botId }));
|
|
102
|
+
// The channel-state convention: this channel's durable home is `<stateRoot>/channels/telegram`
|
|
103
|
+
// (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
|
|
104
|
+
// the operator's ONE state knob (FASTAGENT_STATE_DIR) can never be silently bypassed by glue.
|
|
105
|
+
// The ctx contract says stateRoot is absolute (loadChannels enforces it); re-assert for embedders
|
|
106
|
+
// that mount without the loader — a silent cwd re-anchor is the bug this contract exists to kill,
|
|
107
|
+
// and every derived path (incl. attachment paths) relies on DownloadedFile's absolute-path contract.
|
|
108
|
+
if (!isAbsolute(stateRoot)) {
|
|
109
|
+
throw new Error(`telegramChannel requires an absolute ctx.stateRoot, got "${stateRoot}"`);
|
|
110
|
+
}
|
|
111
|
+
const stateHome = join(stateRoot, "channels", "telegram");
|
|
112
|
+
ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
|
|
113
|
+
const buffer = createContextBuffer(join(stateHome, "buffers.json"));
|
|
114
|
+
// Durable turn intent (L1): persist an accepted turn pre-ACK, remove it when the turn ends; a crash
|
|
115
|
+
// leaves it for replay on the next start. See turn-store.ts for the at-least-once semantics.
|
|
116
|
+
const store = createTurnStore(join(stateHome, "turns.json"));
|
|
117
|
+
const toStored = (r) => {
|
|
118
|
+
const { previewId: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
119
|
+
return { ...intent, attempts: 0 };
|
|
120
|
+
};
|
|
121
|
+
// In-memory: the in-flight "⏳ queued" notice per turn, awaited at dequeue so the turn reliably takes
|
|
122
|
+
// the notice message over (rec.previewId) instead of racing it and orphaning a late-arriving notice.
|
|
123
|
+
const notices = new Map();
|
|
124
|
+
const queue = createTurnQueue({
|
|
125
|
+
label: "[telegram]",
|
|
126
|
+
// Queue feedback: when this session already has a turn running/queued, a silent wait reads as "the
|
|
127
|
+
// bot ignored me" once the current turn runs long — tell the asker NOW (reply-quoted, so it is
|
|
128
|
+
// clear whose ask is queued). Best-effort and post-ACK: a failed notice is a log line, never a
|
|
129
|
+
// failed update. The turn's live preview then edits this same message in place.
|
|
130
|
+
onQueuedBehind: (rec) => {
|
|
131
|
+
const target = {
|
|
132
|
+
chatId: rec.chatId,
|
|
133
|
+
threadId: rec.threadId,
|
|
134
|
+
replyTo: rec.replyTo,
|
|
135
|
+
};
|
|
136
|
+
notices.set(rec.id, sendMessage(apiBaseUrl, botToken, target, "⏳ Queued — I’ll start once the current task finishes.", {
|
|
137
|
+
html: false,
|
|
138
|
+
}).then(
|
|
139
|
+
// The runner holds this same `rec` object, so mutating it here (gated by the notices await at
|
|
140
|
+
// dequeue below) is what hands the turn its preview message id.
|
|
141
|
+
(id) => {
|
|
142
|
+
if (id !== undefined)
|
|
143
|
+
rec.previewId = id;
|
|
144
|
+
}, (e) => log.warn(`[telegram] queue notice failed (the turn still runs): ${String(e)}`)));
|
|
145
|
+
},
|
|
146
|
+
run: async (rec) => {
|
|
147
|
+
// Runs at DEQUEUE time (serialized), so the lifecycle log and engine turn reflect the actual
|
|
148
|
+
// execution order rather than arrival.
|
|
149
|
+
// Settle the queue notice (if any) so rec.previewId is final. NOT free: a slow (not failed)
|
|
150
|
+
// notice delays this turn's start by up to the API timeout — accepted, because racing it would
|
|
151
|
+
// orphan the ⏳ message and double-post a placeholder; in the common path the notice resolved
|
|
152
|
+
// while the previous turn was still running, so this await is instant. BEFORE the ceiling check
|
|
153
|
+
// so a dropped turn's notice is settled/cleared too (rec.previewId lets notifyDropped take it over).
|
|
154
|
+
await notices.get(rec.id);
|
|
155
|
+
notices.delete(rec.id);
|
|
156
|
+
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
157
|
+
const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
|
|
158
|
+
if (decision === "exceeded") {
|
|
159
|
+
// Started MAX_TURN_ATTEMPTS times without finishing — tell the asker (reusing its ⏳ notice if
|
|
160
|
+
// any), drop it.
|
|
161
|
+
notifyDropped(rec);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (decision === "defer") {
|
|
165
|
+
// Couldn't record the attempt (disk failure): skip this cycle. A restart replays it, so no notify
|
|
166
|
+
// (telling the asker to re-ask would double-answer once the deferred turn runs). Two accepted
|
|
167
|
+
// edges of this rare disk-failure corner: (1) the session chain proceeds to the next queued turn,
|
|
168
|
+
// so a deferred turn can replay AFTER its successors — a per-session FIFO reorder; (2) its ⏳ notice
|
|
169
|
+
// (if it was queued behind another turn) now falsely reads "Queued", so delete it best-effort — the
|
|
170
|
+
// eventual replay sends a fresh preview, and leaving it would orphan a stale message above that.
|
|
171
|
+
if (rec.previewId !== undefined) {
|
|
172
|
+
void callApi(apiBaseUrl, botToken, "deleteMessage", {
|
|
173
|
+
chat_id: rec.chatId,
|
|
174
|
+
message_id: rec.previewId,
|
|
175
|
+
}).catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const startedAt = Date.now();
|
|
180
|
+
const where = `chat=${rec.chatId}${rec.threadId !== undefined ? ` thread=${rec.threadId}` : ""}`;
|
|
181
|
+
log.info(`[telegram] turn start: turn=${rec.id} session=${rec.session} ${where}`);
|
|
182
|
+
// Fold the un-summoned discussion since the last answered turn into the prompt; it is cleared
|
|
183
|
+
// only when the turn COMPLETES (then it lives in the session).
|
|
184
|
+
const { text: recent, consumed } = buffer.peek(rec.placeKey);
|
|
185
|
+
const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
|
|
186
|
+
const buffered = collectAttachments(consumed, {
|
|
187
|
+
files: new Set(rec.fileIds),
|
|
188
|
+
images: new Set(rec.imageFileIds),
|
|
189
|
+
});
|
|
190
|
+
const target = {
|
|
191
|
+
chatId: rec.chatId,
|
|
192
|
+
threadId: rec.threadId,
|
|
193
|
+
replyTo: rec.replyTo,
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
await streamReply(invokeTurn(agent, rec.session, prompt, {
|
|
197
|
+
api: apiBaseUrl,
|
|
198
|
+
botToken,
|
|
199
|
+
chatId: rec.chatId,
|
|
200
|
+
filesDir: join(stateHome, "files"),
|
|
201
|
+
}, {
|
|
202
|
+
primary: {
|
|
203
|
+
imageFileIds: rec.imageFileIds,
|
|
204
|
+
fileIds: rec.fileIds,
|
|
205
|
+
},
|
|
206
|
+
buffered,
|
|
207
|
+
}, () => {
|
|
208
|
+
// On completed, ORDER the two durable clears so a crash between them can't replay a
|
|
209
|
+
// context-stripped turn: drop the intent FIRST (a crash after this won't replay it), THEN
|
|
210
|
+
// commit the context buffer (a crash before this just re-folds the same context into the
|
|
211
|
+
// next summon — harmless and additive). The reverse order would leave intent+no-context.
|
|
212
|
+
store.remove(rec.id);
|
|
213
|
+
buffer.commit(rec.placeKey, consumed);
|
|
214
|
+
}), apiBaseUrl, botToken, target, formatError, rec.previewId);
|
|
215
|
+
log.info(`[telegram] turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
log.error(`[telegram] turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
222
|
+
// never reach the completed hook above, which is where a completed turn removes its intent in
|
|
223
|
+
// order. Idempotent — a second remove after the completed hook is a no-op. Only an INTERRUPTED run
|
|
224
|
+
// (this finally never runs — a crash or a SIGTERM deploy, no graceful drain) leaves the record for
|
|
225
|
+
// replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
|
|
226
|
+
store.remove(rec.id);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
// Accept a turn: persist its intent before the ACK (durable), then enqueue it. Recovery re-enqueues a
|
|
231
|
+
// crash-surviving turn WITHOUT re-persisting (it is already on disk with a bumped attempt count).
|
|
232
|
+
const submit = (rec, persist) => {
|
|
233
|
+
if (persist)
|
|
234
|
+
store.add(toStored(rec)); // pre-ACK: a failed write throws → webhook 500 → redeliver
|
|
235
|
+
queue.accept(rec);
|
|
236
|
+
};
|
|
237
|
+
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not
|
|
238
|
+
// just an operator log line. Take over the ⏳ "Queued" notice in place if the turn had one (else send
|
|
239
|
+
// fresh) — leaving it pinned at "Queued" while sending a separate failure would double-post. Best-
|
|
240
|
+
// effort, like the queue notices.
|
|
241
|
+
const notifyDropped = (r) => {
|
|
242
|
+
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
243
|
+
const target = {
|
|
244
|
+
chatId: r.chatId,
|
|
245
|
+
threadId: r.threadId,
|
|
246
|
+
replyTo: r.replyTo,
|
|
247
|
+
};
|
|
248
|
+
const sent = r.previewId !== undefined
|
|
249
|
+
? editMessageText(apiBaseUrl, botToken, target, r.previewId, body, {
|
|
250
|
+
html: false,
|
|
251
|
+
})
|
|
252
|
+
: sendMessage(apiBaseUrl, botToken, target, body, {
|
|
253
|
+
html: false,
|
|
254
|
+
}).then(() => { });
|
|
255
|
+
void sent.catch((e) => log.warn(`[telegram] could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
256
|
+
};
|
|
257
|
+
// Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
|
|
258
|
+
// the queue runs them on the next tick, once this factory returns and the event loop turns. The
|
|
259
|
+
// execution ceiling is enforced per turn at dequeue (run), not here — a never-run turn keeps its budget.
|
|
260
|
+
const recovered = store.recover();
|
|
261
|
+
if (recovered.length > 0)
|
|
262
|
+
log.info(`[telegram] recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
263
|
+
for (const { attempts: _a, ...intent } of recovered)
|
|
264
|
+
submit({ ...intent, previewId: undefined }, false);
|
|
265
|
+
const handler = async (req) => {
|
|
266
|
+
if (req.method !== "POST")
|
|
267
|
+
return text("POST only\n", 405);
|
|
268
|
+
// Fail closed: a missing/wrong secret token is 401, never routed.
|
|
269
|
+
if (!tokenMatches(req.headers.get("x-telegram-bot-api-secret-token") ?? "", secretToken)) {
|
|
270
|
+
return text("invalid secret token\n", 401);
|
|
271
|
+
}
|
|
272
|
+
const body = await readBodyCapped(req, MAX_UPDATE_BYTES);
|
|
273
|
+
if ("tooLarge" in body)
|
|
274
|
+
return text("payload too large\n", 413);
|
|
275
|
+
let update;
|
|
276
|
+
try {
|
|
277
|
+
update = JSON.parse(body.text);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return text("invalid json\n", 400);
|
|
281
|
+
}
|
|
282
|
+
// Decide whether/where to answer, then run the turn. ACK 200 immediately (the turn may outlast the
|
|
283
|
+
// webhook timeout); lifecycle goes to stderr — after the 200 there is no response body, so those
|
|
284
|
+
// lines are the operator's only signal.
|
|
285
|
+
const m = pickMessage(update);
|
|
286
|
+
if (!m)
|
|
287
|
+
return new Response(null, { status: 200 });
|
|
288
|
+
const placeKey = m.message_thread_id ? `${m.chat.id}:${m.message_thread_id}` : `${m.chat.id}`;
|
|
289
|
+
const r = decide(update);
|
|
290
|
+
if (!r) {
|
|
291
|
+
// Not summoned: in a group, record the message so a later summon has the discussion (needs privacy
|
|
292
|
+
// off to be delivered here at all). Empty/service messages and non-group chats keep no buffer.
|
|
293
|
+
const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
|
|
294
|
+
const content = messageText(m);
|
|
295
|
+
if (isGroup && content) {
|
|
296
|
+
// OWN attachments only: each message is its own buffer entry, so a reply's referenced
|
|
297
|
+
// attachment is already (or will be) the other entry's — recounting it here would duplicate
|
|
298
|
+
// downloads and squeeze the attachment cap.
|
|
299
|
+
const fileIds = ownFiles(m);
|
|
300
|
+
const imageIds = ownImages(m);
|
|
301
|
+
// A captioned attachment renders as its caption — append the attachment marker so the fold
|
|
302
|
+
// ALWAYS labels attachments (that label + sender is all the attribution a photo gets).
|
|
303
|
+
const summary = attachmentSummary(m);
|
|
304
|
+
const bodyLine = summary && content !== summary ? `${content} ${summary}` : content;
|
|
305
|
+
buffer.push(placeKey, {
|
|
306
|
+
sender: fromLabel(m.from) ?? "someone",
|
|
307
|
+
body: bodyLine,
|
|
308
|
+
messageId: m.message_id,
|
|
309
|
+
replyTo: m.reply_to_message?.message_id,
|
|
310
|
+
fileIds: fileIds.length ? fileIds : undefined,
|
|
311
|
+
imageIds: imageIds.length ? imageIds : undefined,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return new Response(null, { status: 200 });
|
|
315
|
+
}
|
|
316
|
+
{
|
|
317
|
+
const session = r.session ?? placeKey;
|
|
318
|
+
const chatId = r.chatId ?? m.chat.id;
|
|
319
|
+
// Reply to the summoning message in groups (threads the answer under the asker); a 1:1 DM needs no
|
|
320
|
+
// reply-quote. Only when the RESOLVED target is the message's own chat+thread: a route that
|
|
321
|
+
// redirects elsewhere must not carry a reply_parameters that resolves in the wrong place (fail, or
|
|
322
|
+
// quote a same-id message there). Compare VALUES, not whether the route touched the field — a route
|
|
323
|
+
// that explicitly returns the same chat/thread still quotes.
|
|
324
|
+
const threadId = r.threadId ?? m.message_thread_id;
|
|
325
|
+
const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
|
|
326
|
+
const baseText = r.text ?? telegramEnvelope(m);
|
|
327
|
+
const imageFileIds = extractImages(m);
|
|
328
|
+
const fileIds = extractFiles(m);
|
|
329
|
+
if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
|
|
330
|
+
// Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
|
|
331
|
+
submit({
|
|
332
|
+
id: `${update.update_id}`,
|
|
333
|
+
session,
|
|
334
|
+
placeKey,
|
|
335
|
+
baseText,
|
|
336
|
+
chatId,
|
|
337
|
+
threadId,
|
|
338
|
+
replyTo: m.chat.type !== "private" && sameTarget ? m.message_id : undefined,
|
|
339
|
+
imageFileIds,
|
|
340
|
+
fileIds,
|
|
341
|
+
}, true);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return new Response(null, { status: 200 });
|
|
345
|
+
};
|
|
346
|
+
// Test/observability seam: await the fire-and-forget turns this handler enqueues. Inert in production
|
|
347
|
+
// (nothing reads it; the runtime never drains — see turn-queue), it lets a test await a turn
|
|
348
|
+
// deterministically instead of polling for side effects to settle.
|
|
349
|
+
handler.turnsIdle = () => queue.idle();
|
|
350
|
+
return { "POST /telegram": handler };
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface TurnQueue<T> {
|
|
2
|
+
/** Schedule onto the session's serial chain (runs after that session's previous turn). */
|
|
3
|
+
accept(rec: T): void;
|
|
4
|
+
/** Resolve once no turn is in flight (every per-session chain has drained). A test/observability seam
|
|
5
|
+
* — the production runtime does NOT drain on shutdown (SIGTERM exits mid-turn by design; see the module
|
|
6
|
+
* header), so this is not a graceful-drain hook; it just lets a caller await the fire-and-forget turns. */
|
|
7
|
+
idle(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createTurnQueue<T extends {
|
|
10
|
+
session: string;
|
|
11
|
+
}>(opts: {
|
|
12
|
+
/** Log prefix naming the consumer (e.g. "[telegram]") — the queue itself is channel-neutral. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Executes ONE turn. The queue wraps it: dequeue in FIFO order per session. The runner owns its
|
|
15
|
+
* own error surface; a rejection that escapes anyway is caught and logged here (never a silent
|
|
16
|
+
* unhandled rejection), but the runner's own catch is where diagnosis belongs. */
|
|
17
|
+
run: (rec: T) => Promise<void>;
|
|
18
|
+
/** Fired synchronously when a record is scheduled BEHIND an active turn — the caller's hook for
|
|
19
|
+
* queue feedback to the user (e.g. an "⏳ queued" notice). */
|
|
20
|
+
onQueuedBehind?: (rec: T) => void;
|
|
21
|
+
}): TurnQueue<T>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session serial turn execution: one turn at a time per session (FIFO chains), different
|
|
3
|
+
* sessions concurrent. IN-MEMORY — the runtime queue holds no durability of its own. The engine lease
|
|
4
|
+
* is the corruption floor beneath this; this adds the group-UX queue (a second summon waits its turn
|
|
5
|
+
* instead of colliding on the lease and being dropped as "busy").
|
|
6
|
+
*
|
|
7
|
+
* Channel-neutral (records are opaque beyond a `session` key); it lives in the telegram directory
|
|
8
|
+
* because that is its only consumer today. Durability is layered ON TOP by the caller: turn-store.ts
|
|
9
|
+
* persists an accepted turn's intent pre-ACK and replays a crash-surviving one on the next start (L1,
|
|
10
|
+
* process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
|
|
11
|
+
* backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
|
|
12
|
+
*/
|
|
13
|
+
import { log } from "../../log.js";
|
|
14
|
+
export function createTurnQueue(opts) {
|
|
15
|
+
const { label, run, onQueuedBehind } = opts;
|
|
16
|
+
// Per-session serial chains: a second turn for the same session waits its turn (FIFO) instead of
|
|
17
|
+
// colliding on the engine lease and being dropped as "busy". Different sessions run concurrently.
|
|
18
|
+
const chains = new Map();
|
|
19
|
+
return {
|
|
20
|
+
accept(rec) {
|
|
21
|
+
if (chains.has(rec.session))
|
|
22
|
+
onQueuedBehind?.(rec);
|
|
23
|
+
const prev = chains.get(rec.session) ?? Promise.resolve();
|
|
24
|
+
const task = async () => {
|
|
25
|
+
try {
|
|
26
|
+
await run(rec);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
log.error(`${label} turn runner rejected (session=${rec.session}; own your error surface in run()): ${String(e)}`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const next = prev.then(task, task); // run after this session's previous turn, in arrival order
|
|
33
|
+
chains.set(rec.session, next);
|
|
34
|
+
void next.finally(() => {
|
|
35
|
+
if (chains.get(rec.session) === next)
|
|
36
|
+
chains.delete(rec.session); // drop the entry when drained
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
async idle() {
|
|
40
|
+
// Await outstanding chains until none remain — a chain may schedule a follow-up turn, and each
|
|
41
|
+
// deletes its own map entry on settle, so an emptied map means every turn ran to completion. The
|
|
42
|
+
// tasks catch their own rejections, but allSettled keeps idle() itself total regardless.
|
|
43
|
+
while (chains.size > 0)
|
|
44
|
+
await Promise.allSettled([...chains.values()]);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** An accepted turn's persisted intent — the SOURCE for the fields a runner needs to re-execute it
|
|
2
|
+
* (telegram.ts's PendingTurn derives from this, so a new execution field added here propagates and
|
|
3
|
+
* cannot silently drop from the persisted record). Minus the live `previewId` (a restart's queue
|
|
4
|
+
* notice is gone; a replayed turn sends a fresh preview). `attempts` counts how many times this turn
|
|
5
|
+
* has STARTED executing without finishing (0 until its first run; bumped at each `startAttempt`). */
|
|
6
|
+
export interface StoredTurn {
|
|
7
|
+
id: string;
|
|
8
|
+
session: string;
|
|
9
|
+
placeKey: string;
|
|
10
|
+
baseText: string;
|
|
11
|
+
chatId: number | string;
|
|
12
|
+
threadId?: number;
|
|
13
|
+
replyTo?: number;
|
|
14
|
+
imageFileIds: string[];
|
|
15
|
+
fileIds: string[];
|
|
16
|
+
attempts: number;
|
|
17
|
+
}
|
|
18
|
+
export interface TurnStore {
|
|
19
|
+
/** Persist an accepted turn before the ACK. A failed write throws (→ webhook 500, Telegram redelivers). */
|
|
20
|
+
add(rec: StoredTurn): void;
|
|
21
|
+
/** Remove a finished turn. Post-ACK: a failed write is logged, never thrown (must not abort delivery). */
|
|
22
|
+
remove(id: string): void;
|
|
23
|
+
/** Every persisted turn a crash left behind, in ARRIVAL order, to re-enqueue on the next start. Read-
|
|
24
|
+
* only — the ceiling is enforced per turn at `startAttempt`, so a never-run turn's budget is untouched
|
|
25
|
+
* by a restart. Order matters: the queue rebuilds each session's FIFO chain from re-enqueue order. */
|
|
26
|
+
recover(): StoredTurn[];
|
|
27
|
+
/** Called when a turn is about to RUN (dequeued). Returns:
|
|
28
|
+
* - "run": bumped its persisted execution count; go ahead.
|
|
29
|
+
* - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
|
|
30
|
+
* cause); the record is dropped and the runner notifies the asker.
|
|
31
|
+
* - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
|
|
32
|
+
* would let a poison turn re-run forever); the record stays on disk and replays on the next start
|
|
33
|
+
* (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
|
|
34
|
+
* An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
|
|
35
|
+
* redelivery-double-run tail (see the header's pre-ACK window) lands here. */
|
|
36
|
+
startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
|
|
37
|
+
}
|
|
38
|
+
export declare function createTurnStore(path: string): TurnStore;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable turn intent: the L1 half of durable execution (the L2 exactly-once / deterministic
|
|
3
|
+
* step-replay layer is the K-axis backend — docs/design/core.md §11). Persists an accepted turn BEFORE
|
|
4
|
+
* the webhook 200 (pre-ACK, like context-buffer) and removes it when the turn ENDS — the runner's
|
|
5
|
+
* `finally`: a completed turn OR any caught error both remove it. Precisely, a completed turn's removal
|
|
6
|
+
* fires at the `completed` event (the session is committed), which is BEFORE streamReply delivers the
|
|
7
|
+
* answer to Telegram: so L1 recovers the ACKed-but-un-COMPLETED window, not un-DELIVERED. A crash in
|
|
8
|
+
* that narrow tail (completed, session-committed, but the message not yet sent) leaves the answer in the
|
|
9
|
+
* session history undelivered and is deliberately NOT replayed — replaying a session-committed turn
|
|
10
|
+
* would double-append it; the asker re-asks (and sees the prior answer in history). L1's scope is the
|
|
11
|
+
* INTERRUPTED-run window (the `finally` never runs); a caught error is NOT retried here — a `failed`
|
|
12
|
+
* event already told the user, and a transport throw is dropped exactly like the pre-L1 in-memory queue
|
|
13
|
+
* did (replaying it could double-send). Only an interrupted run leaves the record on disk — and
|
|
14
|
+
* "interrupted" is not just a rare crash: `runStart` has no graceful drain (cli.ts), so a SIGTERM exits
|
|
15
|
+
* mid-turn too, i.e. EVERY rolling deploy that catches an in-flight turn. Recovery re-enqueues it next start.
|
|
16
|
+
*
|
|
17
|
+
* This recovers the ACKed-but-unfinished window the in-memory turn-queue drops (turn-queue.ts). Weigh
|
|
18
|
+
* the trade before trusting it: the alternative (dropping the turn) fails VISIBLY and self-corrects
|
|
19
|
+
* (the turn vanished, the asker re-asks); replay's sharpest cost is the opposite — invisible. It is
|
|
20
|
+
* at-least-once, not exactly-once:
|
|
21
|
+
* - PRIMARY cost: replay re-runs the WHOLE turn, so every external side effect happens AGAIN — re-sent
|
|
22
|
+
* messages, re-fired tool actions — and nobody may notice (unlike the visible loss it replaces).
|
|
23
|
+
* And the trigger is not rare (above): it fires on every deploy that interrupts an in-flight turn,
|
|
24
|
+
* not just on crashes. So "safe only if the turn's tools are idempotent" is a bar to judge against
|
|
25
|
+
* DEPLOY frequency: it holds for a Q&A bot, and is the gate for adding side-effecting tools. (A
|
|
26
|
+
* mid-stream interruption also leaves an orphan "💭 Thinking…" preview — cosmetic.)
|
|
27
|
+
* - The pre-ACK window overlaps Telegram redelivery: a crash AFTER the persist but BEFORE the 200
|
|
28
|
+
* means recovery replays the turn AND Telegram (never-ACKed) redelivers the same update — same
|
|
29
|
+
* update_id, but the queue does not dedup, so the turn can run twice. Exactly-once (a persisted
|
|
30
|
+
* delivery key) is L2.
|
|
31
|
+
*
|
|
32
|
+
* ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
|
|
33
|
+
* crashes the process would replay forever under a container restart policy. The counter is bumped at
|
|
34
|
+
* `startAttempt` — when a turn is about to RUN, not per restart cycle — so it counts a turn's OWN
|
|
35
|
+
* execution attempts: `recover()` re-enqueues every surviving turn without touching its count, and a
|
|
36
|
+
* poison turn at a session's head is dropped on its own N+1th run WITHOUT penalizing the never-run turns
|
|
37
|
+
* queued behind it (they keep their full budget and get their turn once the poison one is gone). On drop,
|
|
38
|
+
* the runner notifies the asker (the chain's end must get a signal — a log line the user can't see isn't).
|
|
39
|
+
* The bump is the ONE post-ACK write that fails CLOSED (not best-effort): if it can't be persisted the
|
|
40
|
+
* turn is DEFERRED — skipped this cycle and left on disk to replay on the next start — rather than run
|
|
41
|
+
* untracked, because an unpersisted count lets recover() re-run a poison turn every restart (the ceiling
|
|
42
|
+
* would never advance on disk). It replays on the next START — a restart is required; disk recovery alone
|
|
43
|
+
* does not re-run a deferred turn — and the asker is NOT told (a transient system degrade, not a user-
|
|
44
|
+
* actionable failure — telling them "ask again" would double-answer on that replay).
|
|
45
|
+
* Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
|
|
46
|
+
* power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
|
|
47
|
+
*/
|
|
48
|
+
import { log } from "../../log.js";
|
|
49
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
50
|
+
/** State files are an IO boundary: valid JSON of the WRONG SHAPE must degrade like a corrupt file
|
|
51
|
+
* (warn + empty), not flow in as trusted data (mirrors context-buffer's isBufferEntry). */
|
|
52
|
+
function isStoredTurn(t) {
|
|
53
|
+
const r = t;
|
|
54
|
+
const strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
55
|
+
return (typeof r?.id === "string" &&
|
|
56
|
+
typeof r.session === "string" &&
|
|
57
|
+
typeof r.placeKey === "string" &&
|
|
58
|
+
typeof r.baseText === "string" &&
|
|
59
|
+
(typeof r.chatId === "string" || typeof r.chatId === "number") &&
|
|
60
|
+
(r.threadId === undefined || typeof r.threadId === "number") &&
|
|
61
|
+
(r.replyTo === undefined || typeof r.replyTo === "number") &&
|
|
62
|
+
strings(r.imageFileIds) &&
|
|
63
|
+
strings(r.fileIds) &&
|
|
64
|
+
typeof r.attempts === "number");
|
|
65
|
+
}
|
|
66
|
+
export function createTurnStore(path) {
|
|
67
|
+
const load = () => {
|
|
68
|
+
const raw = loadStateFile(path);
|
|
69
|
+
if (raw === undefined)
|
|
70
|
+
return new Map();
|
|
71
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.values(raw).every(isStoredTurn)) {
|
|
72
|
+
return new Map(Object.entries(raw));
|
|
73
|
+
}
|
|
74
|
+
log.warn(`[telegram] unexpected shape in ${path} — starting with no pending turns`);
|
|
75
|
+
return new Map();
|
|
76
|
+
};
|
|
77
|
+
const turns = load();
|
|
78
|
+
const persist = () => saveStateFile(path, Object.fromEntries(turns));
|
|
79
|
+
// Post-ACK writes (remove, startAttempt) must not abort a turn: log a failed write, never throw.
|
|
80
|
+
const persistBestEffort = (what) => {
|
|
81
|
+
try {
|
|
82
|
+
persist();
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
log.error(`[telegram] turn-store ${what} write failed post-ACK: ${String(e)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
add(rec) {
|
|
90
|
+
// Idempotent on re-add: a redelivery (pre-ACK crash → never-ACKed update replayed) re-submits an id
|
|
91
|
+
// already in the store. Skip it — the intent is already durable, and overwriting would RESET this
|
|
92
|
+
// turn's execution count, handing a poison turn a fresh ceiling budget.
|
|
93
|
+
if (turns.has(rec.id))
|
|
94
|
+
return;
|
|
95
|
+
turns.set(rec.id, rec);
|
|
96
|
+
try {
|
|
97
|
+
persist(); // pre-ACK: a throw becomes the webhook's 500 and Telegram redelivers
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
// Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
|
|
101
|
+
// entry makes the redelivery's `add` short-circuit on `turns.has` — running the turn with its
|
|
102
|
+
// intent never persisted, defeating this module — and gets flushed later by an unrelated persist.
|
|
103
|
+
turns.delete(rec.id);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
remove(id) {
|
|
108
|
+
if (turns.delete(id))
|
|
109
|
+
persistBestEffort("remove (a restart may replay an answered turn)");
|
|
110
|
+
},
|
|
111
|
+
recover() {
|
|
112
|
+
// Arrival order, so the queue rebuilds each session's FIFO chain correctly. Ids are Telegram
|
|
113
|
+
// update_ids — monotonic — so numeric id order IS arrival order; sort on it explicitly rather than
|
|
114
|
+
// lean on JS object-key enumeration happening to survive the load's JSON round-trip.
|
|
115
|
+
return [...turns.values()].sort((a, b) => Number(a.id) - Number(b.id));
|
|
116
|
+
},
|
|
117
|
+
startAttempt(id, maxAttempts) {
|
|
118
|
+
const rec = turns.get(id);
|
|
119
|
+
if (!rec)
|
|
120
|
+
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
121
|
+
const attempts = rec.attempts + 1;
|
|
122
|
+
if (attempts > maxAttempts) {
|
|
123
|
+
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
124
|
+
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
125
|
+
log.error(`[telegram] dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
126
|
+
`(session=${rec.session}) — it may be crashing the process, or was killed mid-run each time; notifying the asker`);
|
|
127
|
+
turns.delete(id);
|
|
128
|
+
persistBestEffort("drop");
|
|
129
|
+
return "exceeded";
|
|
130
|
+
}
|
|
131
|
+
turns.set(id, { ...rec, attempts });
|
|
132
|
+
try {
|
|
133
|
+
persist();
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
// The bump MUST be durable, unlike remove/drop: if it isn't, a restart's recover() reads the old
|
|
137
|
+
// count and RE-RUNS this turn — a poison turn would re-execute forever (the ceiling never advances
|
|
138
|
+
// on disk). Fail closed: roll the memory bump back (match disk) and DEFER — the record stays on
|
|
139
|
+
// disk with its old count and replays on the next start (a restart is required; disk recovery
|
|
140
|
+
// alone won't re-run it). Not a drop: under disk failure the removal couldn't persist anyway.
|
|
141
|
+
// The runner skips silently (no notify).
|
|
142
|
+
turns.set(id, rec);
|
|
143
|
+
log.error(`[telegram] cannot persist turn ${id}'s attempt count — deferring it to the next start rather ` +
|
|
144
|
+
`than run it untracked (session=${rec.session}): ${String(e)}`);
|
|
145
|
+
return "defer";
|
|
146
|
+
}
|
|
147
|
+
return "run";
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI presenter for the auth-status line `reportAuth` prints (invoke/dev/start). Kept out of cli.ts —
|
|
3
|
+
* which self-executes on import — so the expired-vs-missing DECISION is unit-testable without a real
|
|
4
|
+
* credential round-trip, mirroring cli-models.ts.
|
|
5
|
+
*/
|
|
6
|
+
/** A stored credential as `reportAuth` needs it: just its kind, for the status line. */
|
|
7
|
+
export interface StoredCredentialInfo {
|
|
8
|
+
type: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Format the auth status for `spec`'s provider. `source` is {@link probeAuthSource}'s label (an env-var
|
|
12
|
+
* name, "OAuth", a stored-key label) or undefined when nothing currently SATISFIES auth. Undefined has
|
|
13
|
+
* TWO causes probeAuthSource can't distinguish (it swallows the throw): nothing stored, OR a credential
|
|
14
|
+
* that IS stored but couldn't be made usable (an expired/revoked OAuth whose refresh failed). `stored`
|
|
15
|
+
* — a refresh-FREE store read the caller does only in that case — tells them apart, so an expired login
|
|
16
|
+
* reports "expired/unusable → `fastagent login`" instead of a misleading "(none found)" (which would then
|
|
17
|
+
* be contradicted by the actual "OAuth refresh failed"). A malformed/unreadable store also reads as
|
|
18
|
+
* undefined `stored` and lands in "(none found)" — but the store's own read warns about the corrupt file,
|
|
19
|
+
* so the real signal is surfaced there; this line stays about credential PRESENCE.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatAuthReport(provider: string, authPath: string, source: string | undefined, stored: StoredCredentialInfo | undefined): {
|
|
22
|
+
line: string;
|
|
23
|
+
warn?: string;
|
|
24
|
+
};
|