@fastagent-sh/fastagent 0.19.0 → 0.20.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.
Files changed (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -1,13 +1,12 @@
1
1
  /** Slack Web API transport: one JSON pipeline plus authenticated, capped private-file downloads. */
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { attachmentPath } from "../kit/attachment-path.js";
4
4
  import { codePointPrefix } from "../kit/text.js";
5
5
  const API_TIMEOUT_MS = 30_000;
6
6
  const DOWNLOAD_TIMEOUT_MS = 120_000;
7
7
  const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
8
8
  const RETRIES = 3;
9
9
  const MAX_RETRY_AFTER_S = 30;
10
- const MAX_REDIRECTS = 3;
11
10
  /** Slack's standard-Markdown fields cap each call at 12,000 characters. Keep headroom for
12
11
  * code-fence balancing and future server-side transformations. */
13
12
  const SLACK_MAX_MARKDOWN = 10_000;
@@ -95,11 +94,6 @@ export function chunkSlackMarkdown(markdown, maxPoints = SLACK_MAX_MARKDOWN) {
95
94
  }
96
95
  return output;
97
96
  }
98
- function safeFileName(file) {
99
- const raw = file.name ?? file.title ?? file.id ?? "file";
100
- const safe = raw.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
101
- return `${file.id ?? "slack"}-${safe}`;
102
- }
103
97
  function fileDownloadUrl(file) {
104
98
  if (file.file_access === "check_file_info")
105
99
  throw new Error(`Slack file ${file.id ?? "(unknown)"} is not ready`);
@@ -140,23 +134,7 @@ async function readBytesCapped(response) {
140
134
  }
141
135
  export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
142
136
  const apiBase = baseUrl.replace(/\/$/, "");
143
- const configuredOrigin = new URL(apiBase).origin;
144
137
  const currentToken = typeof botToken === "string" ? async () => botToken : botToken;
145
- const trustedDownloadUrl = (value) => {
146
- const url = new URL(value);
147
- const host = url.hostname.toLowerCase();
148
- const slackHost = host === "slack-files.com" ||
149
- host.endsWith(".slack-files.com") ||
150
- host === "slack.com" ||
151
- host.endsWith(".slack.com") ||
152
- host === "slack-edge.com" ||
153
- host.endsWith(".slack-edge.com");
154
- if (!slackHost && url.origin !== configuredOrigin)
155
- throw new Error(`refusing non-Slack file URL host ${host}`);
156
- if (url.protocol !== "https:" && url.origin !== configuredOrigin)
157
- throw new Error("refusing non-HTTPS Slack file URL");
158
- return url;
159
- };
160
138
  const call = async (method, body, httpMethod = "POST") => {
161
139
  const url = new URL(`${apiBase}/${method}`);
162
140
  if (httpMethod === "GET") {
@@ -211,36 +189,26 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
211
189
  throw new SlackApiError(method, response.status, detail, data.error);
212
190
  }
213
191
  };
192
+ // Slack owns both halves of this download: the URL comes from its own files.info response, and it
193
+ // documents url_private as taking our bearer token. Where that URL leads is Slack's call; whether a
194
+ // redirect hop still carries the token is fetch's, which drops the header cross-origin.
214
195
  const download = async (file) => {
215
- let url = trustedDownloadUrl(fileDownloadUrl(file));
216
- for (let redirect = 0;; redirect++) {
217
- let response;
218
- try {
219
- const token = await currentToken();
220
- response = await fetch(url, {
221
- headers: { authorization: `Bearer ${token}` },
222
- redirect: "manual",
223
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
224
- });
225
- }
226
- catch (error) {
227
- throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
228
- }
229
- if (response.status >= 300 && response.status < 400) {
230
- const location = response.headers.get("location");
231
- if (!location || redirect >= MAX_REDIRECTS) {
232
- throw new SlackApiError("file download", response.status, "invalid or excessive redirect");
233
- }
234
- await response.body?.cancel().catch(() => { });
235
- url = trustedDownloadUrl(new URL(location, url).toString());
236
- continue;
237
- }
238
- if (!response.ok) {
239
- const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
240
- throw new SlackApiError("file download", response.status, detail);
241
- }
242
- return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
196
+ let response;
197
+ try {
198
+ const token = await currentToken();
199
+ response = await fetch(fileDownloadUrl(file), {
200
+ headers: { authorization: `Bearer ${token}` },
201
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
202
+ });
203
+ }
204
+ catch (error) {
205
+ throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
206
+ }
207
+ if (!response.ok) {
208
+ const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
209
+ throw new SlackApiError("file download", response.status, detail);
243
210
  }
211
+ return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
244
212
  };
245
213
  const api = {
246
214
  async authTest() {
@@ -387,10 +355,10 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
387
355
  },
388
356
  async fetchFile(file, channelId, filesDir) {
389
357
  const { bytes } = await download(file);
390
- const dir = join(filesDir, channelId);
358
+ // The id prefix keeps two same-named uploads in one channel apart.
359
+ const suggested = `${file.id ?? "slack"}-${file.name ?? file.title ?? file.id ?? "file"}`;
360
+ const { dir, name, path } = attachmentPath(filesDir, channelId, suggested);
391
361
  await mkdir(dir, { recursive: true });
392
- const name = safeFileName(file);
393
- const path = join(dir, name);
394
362
  await writeFile(path, bytes);
395
363
  return { path, name, size: bytes.byteLength };
396
364
  },
@@ -5,13 +5,15 @@ import { log } from "../../log.js";
5
5
  import { readBodyCapped } from "../body.js";
6
6
  import { text } from "../respond.js";
7
7
  import { createSeenRing } from "../kit/seen.js";
8
+ import { signatureIsFresh } from "../kit/signature.js";
8
9
  import { createThreadParticipants } from "../kit/thread-participants.js";
9
10
  import { createTaskTracker } from "../kit/tasks.js";
10
11
  import { ensureStateHome } from "../kit/state.js";
11
12
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
12
13
  import { codePointPrefix } from "../kit/text.js";
13
14
  import { createTurnQueue } from "../kit/turn-queue.js";
14
- import { createTurnStore } from "../kit/turn-store.js";
15
+ import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
16
+ import { discussionBlock } from "../kit/context-buffer.js";
15
17
  import { createSlackBotTokenProvider } from "./bot-auth.js";
16
18
  import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
17
19
  import { invokeSlackTurn } from "./invoke-turn.js";
@@ -22,7 +24,8 @@ import { createSlackApi } from "./slack-api.js";
22
24
  import { createWelcomedUsers } from "./welcomed.js";
23
25
  export { defaultSlackRoute, slackEnvelope };
24
26
  const MAX_EVENT_BYTES = 1 << 20;
25
- const MAX_TURN_ATTEMPTS = 3;
27
+ /** Slack's own documented window — it re-signs every redelivery with a current timestamp, so a tight
28
+ * one costs nothing. The Feishu ingress reads hours off the same helper for the opposite reason. */
26
29
  const MAX_SIGNATURE_AGE_S = 5 * 60;
27
30
  const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
28
31
  const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
@@ -45,10 +48,9 @@ function isStoredSlackTurn(value) {
45
48
  }
46
49
  /** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
47
50
  export function verifySlackSignature(signingSecret, timestamp, signature, rawBody, nowMs = Date.now()) {
48
- if (!/^\d+$/.test(timestamp) || !/^v0=[a-f0-9]{64}$/i.test(signature))
51
+ if (!/^v0=[a-f0-9]{64}$/i.test(signature))
49
52
  return false;
50
- const seconds = Number(timestamp);
51
- if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(nowMs / 1000) - seconds) > MAX_SIGNATURE_AGE_S)
53
+ if (!signatureIsFresh(timestamp, MAX_SIGNATURE_AGE_S, nowMs))
52
54
  return false;
53
55
  const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
54
56
  const actualBytes = Buffer.from(signature);
@@ -134,6 +136,9 @@ export function slackChannel(options) {
134
136
  });
135
137
  });
136
138
  };
139
+ // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle. Declared
140
+ // ahead of its consumers so no construction-time path can reach it in the temporal dead zone.
141
+ const sideTasks = createTaskTracker(label);
137
142
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
138
143
  const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
144
  /** A thread's participation is keyed by the SESSION it describes — "the agent answered here" is a
@@ -186,7 +191,7 @@ export function slackChannel(options) {
186
191
  log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
187
192
  return;
188
193
  }
189
- const attempt = store.startAttempt(turn.id, MAX_TURN_ATTEMPTS);
194
+ const attempt = store.startAttempt(turn.id);
190
195
  if (attempt === "exceeded") {
191
196
  notifyDropped(turn);
192
197
  return;
@@ -205,7 +210,7 @@ export function slackChannel(options) {
205
210
  const startedAt = Date.now();
206
211
  log.info(`${label} turn start: turn=${turn.id} session=${turn.session} channel=${turn.channelId}`);
207
212
  const { text: recent, consumed } = buffer.peek(turn.bufferKey);
208
- const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${turn.baseText}` : turn.baseText;
213
+ const prompt = `${discussionBlock(recent)}${turn.baseText}`;
209
214
  const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
210
215
  const messageRef = messageRefOf(turn.id);
211
216
  const reaction = reactionEmojis && messageRef
@@ -218,10 +223,7 @@ export function slackChannel(options) {
218
223
  })
219
224
  : undefined;
220
225
  try {
221
- await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => {
222
- store.remove(turn.id);
223
- buffer.commit(turn.bufferKey, consumed);
224
- }), api, targetOf(turn), formatError, {
226
+ await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => commitAnsweredTurn(store, buffer, { id: turn.id, bufferKey: turn.bufferKey, consumed })), api, targetOf(turn), formatError, {
225
227
  rendering,
226
228
  initialPreviewTs: turn.previewTs,
227
229
  threadTitle: turn.threadTitle,
@@ -410,8 +412,6 @@ export function slackChannel(options) {
410
412
  });
411
413
  }
412
414
  };
413
- // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
414
- const sideTasks = createTaskTracker();
415
415
  // First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
416
416
  const welcomeInFlight = new Set();
417
417
  const maybeWelcome = (envelope) => {
@@ -139,12 +139,32 @@ export declare function messageText(m: TelegramMessage): string;
139
139
  * `text: `${telegramEnvelope(m)}\n\n[extra]``. The channel still appends downloaded attachments.
140
140
  */
141
141
  export declare function telegramEnvelope(m: TelegramMessage): string;
142
+ /**
143
+ * Whether the update is a `/stop` ADDRESSED TO THIS BOT — the one question the channel asks about a
144
+ * stop, because an unaddressed one is not a command it may act on. Four ways to address it, all
145
+ * equivalent: a bare `/stop` in a private chat (no one else is there), `/stop@thisbot`, an `@thisbot`
146
+ * mention beside the command, and a bare `/stop` replying to one of this bot's messages.
147
+ *
148
+ * A BARE `/stop` in a group is deliberately NOT one of them. Telegram hands it to every bot in the
149
+ * chat — and only promises to deliver it at all when this bot spoke last — so it names no one: acting
150
+ * on it lets a bystander's ask for a different bot abort this bot's run, and answering it lets that
151
+ * bystander make this bot talk. It stays ordinary discussion, which is what the group sees anyway.
152
+ *
153
+ * Takes the UPDATE, like `route` itself, so a custom route can ask it directly — that route is the
154
+ * gate a stop must pass, and one that refuses this update silences the command.
155
+ */
156
+ export declare function telegramStop(update: TelegramUpdate, options?: {
157
+ botUsername?: string;
158
+ botId?: number;
159
+ }): boolean;
142
160
  /**
143
161
  * The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
144
162
  * answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
145
163
  * naming it (when `botUsername` is supplied — telegramChannel parses the id from the token and resolves
146
164
  * the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
147
- * a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
165
+ * a bot author who wants commands adds a custom route) except a `/stop` ADDRESSED to this bot, which
166
+ * must reach the run it stops rather than sit in the buffer behind it (see {@link telegramStop}; a
167
+ * bare group `/stop` addresses no one and stays ordinary discussion). Returns `{}` (act; the channel fills
148
168
  * session/target/prompt from the message) or `null` (ignore).
149
169
  */
150
170
  export declare function defaultTelegramRoute(update: TelegramUpdate, options?: {
@@ -108,20 +108,66 @@ function botName(botUsername) {
108
108
  return s || undefined;
109
109
  }
110
110
  /**
111
- * Whether the message @mentions the bot — read from the `mention` ENTITIES Telegram's server already
112
- * parsed, not a regex over the raw text. The entity type excludes by construction what a text scan
113
- * false-matches: `@bot` inside a code block or a URL is not a `mention` entity, a glued `/cmd@bot` is a
114
- * `bot_command` — and slicing the exact offset/length range makes `@fast` vs `@fastagent` confusion
115
- * impossible. (Offsets are UTF-16 code units = native JS string indexing.) No text fallback: mention
116
- * entities are produced server-side, so their absence means there is no mention.
111
+ * The `mention` ENTITIES naming THIS bot — read from what Telegram's server already parsed, not a
112
+ * regex over the raw text. The entity type excludes by construction what a text scan false-matches:
113
+ * `@bot` inside a code block or a URL is not a `mention` entity, a glued `/cmd@bot` is a `bot_command`
114
+ * — and slicing the exact offset/length range makes `@fast` vs `@fastagent` confusion impossible.
115
+ * (Offsets are UTF-16 code units = native JS string indexing.) No text fallback: mention entities are
116
+ * produced server-side, so their absence means there is no mention. Empty when the bot does not know
117
+ * its own username — "which of these names me?" has no answer then, and guessing would mis-summon.
117
118
  */
118
- function mentionsBot(m, botUsername) {
119
+ function botMentions(m, botUsername) {
119
120
  const name = botName(botUsername);
120
121
  if (!name)
121
- return false;
122
+ return [];
122
123
  const text = m.text ?? m.caption ?? "";
123
124
  const entities = (m.text !== undefined ? m.entities : m.caption_entities) ?? [];
124
- return entities.some((e) => e.type === "mention" && text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${name}`);
125
+ return entities.filter((e) => e.type === "mention" && text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${name}`);
126
+ }
127
+ /** Whether the message @mentions the bot. */
128
+ function mentionsBot(m, botUsername) {
129
+ return botMentions(m, botUsername).length > 0;
130
+ }
131
+ /** The message text with THIS bot's mentions cut out, so a command addressed to it reads as the bare
132
+ * command. Only its own: a message that names someone else (`@otherbot /stop`) must keep that name,
133
+ * or cutting it would turn an ask aimed elsewhere into this bot's command. Cut from the end so the
134
+ * earlier offsets stay valid (they are UTF-16 code units = native JS indexing). */
135
+ function textWithoutBotMentions(m, botUsername) {
136
+ const text = m.text ?? m.caption ?? "";
137
+ return botMentions(m, botUsername)
138
+ .sort((a, b) => b.offset - a.offset)
139
+ .reduce((out, e) => out.slice(0, e.offset) + out.slice(e.offset + e.length), text)
140
+ .trim();
141
+ }
142
+ /**
143
+ * Whether the update is a `/stop` ADDRESSED TO THIS BOT — the one question the channel asks about a
144
+ * stop, because an unaddressed one is not a command it may act on. Four ways to address it, all
145
+ * equivalent: a bare `/stop` in a private chat (no one else is there), `/stop@thisbot`, an `@thisbot`
146
+ * mention beside the command, and a bare `/stop` replying to one of this bot's messages.
147
+ *
148
+ * A BARE `/stop` in a group is deliberately NOT one of them. Telegram hands it to every bot in the
149
+ * chat — and only promises to deliver it at all when this bot spoke last — so it names no one: acting
150
+ * on it lets a bystander's ask for a different bot abort this bot's run, and answering it lets that
151
+ * bystander make this bot talk. It stays ordinary discussion, which is what the group sees anyway.
152
+ *
153
+ * Takes the UPDATE, like `route` itself, so a custom route can ask it directly — that route is the
154
+ * gate a stop must pass, and one that refuses this update silences the command.
155
+ */
156
+ export function telegramStop(update, options) {
157
+ const m = pickMessage(update);
158
+ if (!m)
159
+ return false;
160
+ // This bot's own mentions are cut out before matching: `@thisbot /stop` is the same command as
161
+ // `/stop@thisbot`. Anyone else's stays in the text, so `@otherbot /stop` never reduces to a command
162
+ // here — not even when it also replies to this bot.
163
+ const match = /^\/stop(?:@([A-Za-z0-9_]+))?$/i.exec(textWithoutBotMentions(m, options?.botUsername));
164
+ if (!match)
165
+ return false;
166
+ // `/stop@name` states its addressee: ours only when the name is ours, and never when this bot does
167
+ // not know its own username (fail closed — the same rule reply/mention summon follows).
168
+ if (match[1] !== undefined)
169
+ return match[1].toLowerCase() === botName(options?.botUsername);
170
+ return m.chat.type === "private" || mentionsBot(m, options?.botUsername) || repliesToBot(m, options);
125
171
  }
126
172
  /** Whether the message replies to THIS bot — not just any bot: in a multi-bot group, replying to
127
173
  * another bot must not summon ours. Identity is the bot's numeric id (stable; a username is a mutable
@@ -143,13 +189,21 @@ function repliesToBot(m, options) {
143
189
  * answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
144
190
  * naming it (when `botUsername` is supplied — telegramChannel parses the id from the token and resolves
145
191
  * the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
146
- * a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
192
+ * a bot author who wants commands adds a custom route) except a `/stop` ADDRESSED to this bot, which
193
+ * must reach the run it stops rather than sit in the buffer behind it (see {@link telegramStop}; a
194
+ * bare group `/stop` addresses no one and stays ordinary discussion). Returns `{}` (act; the channel fills
147
195
  * session/target/prompt from the message) or `null` (ignore).
148
196
  */
149
197
  export function defaultTelegramRoute(update, options) {
150
198
  const m = pickMessage(update);
151
199
  if (!m)
152
200
  return null;
153
- const summoned = m.chat.type === "private" || repliesToBot(m, options) || mentionsBot(m, options?.botUsername);
201
+ const summoned = m.chat.type === "private" ||
202
+ repliesToBot(m, options) ||
203
+ mentionsBot(m, options?.botUsername) ||
204
+ // Adds exactly ONE case to the three above: the `/stop@thisbot` suffix form. Every other way to
205
+ // address a stop (private, mention, reply) is already a summon on its own — telegramStop repeats
206
+ // those three by design, since it must answer "addressed to me?" without a route to lean on.
207
+ telegramStop(update, options);
154
208
  return summoned ? {} : null;
155
209
  }
@@ -15,11 +15,15 @@ export default telegramChannel({
15
15
  // chat is customer-facing by default — for a public bot, drop this or return a neutral string;
16
16
  // full details always go to the server log regardless.
17
17
  onError: (failed) => `⚠️ ${failed.details}`,
18
+ // Your bot's @username. Optional — resolved via getMe when omitted — but set it if you pass a `route`
19
+ // below: the channel needs its own copy to recognise a `/stop` addressed to it.
20
+ // botUsername: "your_bot",
18
21
  // The channel owns transport + format (HTML) + attachments (photo→vision, file→disk) + streaming.
19
22
  // `route` (POLICY) is OPTIONAL — omitted, it uses defaultTelegramRoute: private chats always answer,
20
- // groups only on a reply to THIS bot or an @mention of it. Override to customise, reusing the
21
- // export — but pass your bot's identity: group summon needs it (the omitted default gets it from
22
- // the channel; a bare `defaultTelegramRoute(u)` answers only private chats):
23
+ // groups only on a reply to THIS bot, an @mention of it, or a `/stop` addressed to it. Override to
24
+ // customise, reusing the export — but pass your bot's identity BOTH here and as `botUsername` above
25
+ // (the omitted default gets it from the channel; a bare `defaultTelegramRoute(u)` answers only
26
+ // private chats):
23
27
  // route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { session: `user:${u.message?.from?.id}` },
24
28
  // route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { text: `${telegramEnvelope(u.message!)}\n[extra]` },
25
29
  });
@@ -16,7 +16,8 @@
16
16
  * parse fallback, and the getFile→download dance. (telegram.ts orchestrates; no agent, no rendering.)
17
17
  */
18
18
  import { mkdir, writeFile } from "node:fs/promises";
19
- import { basename, join } from "node:path";
19
+ import { basename } from "node:path";
20
+ import { attachmentPath } from "../kit/attachment-path.js";
20
21
  /** Telegram's hard text limit per message. */
21
22
  export const TELEGRAM_MAX_TEXT = 4096;
22
23
  const PARSE_ERROR = /can't parse|entit|unsupported|unclosed|tag/i;
@@ -317,12 +318,10 @@ async function fetchTelegramImage(api, botToken, fileId) {
317
318
  /** Download a Telegram file_id to <filesDir>/<chat>/<name>. Throws on failure / oversize. */
318
319
  async function downloadTelegramFile(api, botToken, fileId, chatId, filesDir) {
319
320
  const { bytes, remotePath } = await getFileBytes(api, botToken, fileId);
320
- const name = basename(remotePath);
321
- const dir = join(filesDir, String(chatId));
321
+ const { dir, name, path } = attachmentPath(filesDir, chatId, basename(remotePath));
322
322
  await mkdir(dir, { recursive: true });
323
- const dest = join(dir, name);
324
- await writeFile(dest, bytes);
325
- return { path: dest, name, size: bytes.byteLength };
323
+ await writeFile(path, bytes);
324
+ return { path, name, size: bytes.byteLength };
326
325
  }
327
326
  /** Fetch the message's images. Throws if any cannot be loaded — the caller surfaces it (no silent drop). */
328
327
  export async function resolveImages(api, botToken, fileIds) {
@@ -1,7 +1,7 @@
1
1
  import type { ChannelModule } from "../../channel.ts";
2
- import { type TelegramMessage, type TelegramRoute, type TelegramUpdate, defaultTelegramRoute, telegramEnvelope } from "./parse.ts";
2
+ import { type TelegramMessage, type TelegramRoute, type TelegramUpdate, defaultTelegramRoute, telegramEnvelope, telegramStop } from "./parse.ts";
3
3
  import { type TelegramFailure } from "./preview.ts";
4
- export { defaultTelegramRoute, telegramEnvelope };
4
+ export { defaultTelegramRoute, telegramEnvelope, telegramStop };
5
5
  export type { TelegramFailure, TelegramMessage, TelegramRoute, TelegramUpdate };
6
6
  export interface TelegramChannelOptions {
7
7
  /** Webhook secret token (the `secret_token` you set via setWebhook); verifies inbound updates. */
@@ -29,25 +29,17 @@ import { readBodyCapped } from "../body.js";
29
29
  import { text } from "../respond.js";
30
30
  import { invokeTurn } from "./invoke-turn.js";
31
31
  import { collectAttachments, createContextBuffer } from "./context-buffer.js";
32
- import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, } from "./parse.js";
32
+ import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, telegramStop, } from "./parse.js";
33
33
  import { defaultErrorMessage, streamReply } from "./preview.js";
34
34
  import { ensureStateHome } from "../kit/state.js";
35
35
  import { dispatchStop } from "../kit/stop-command.js";
36
36
  import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
37
37
  import { createTurnQueue } from "../kit/turn-queue.js";
38
+ import { commitAnsweredTurn } from "../kit/turn-store.js";
39
+ import { discussionBlock } from "../kit/context-buffer.js";
38
40
  import { createTurnStore } from "./turn-store.js";
39
41
  // Re-export the public surface authored elsewhere, so `@fastagent-sh/fastagent/telegram` keeps one entry point.
40
- export { defaultTelegramRoute, telegramEnvelope };
41
- /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
42
- * rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
43
- * at dequeue, so a never-run turn queued behind a poison one keeps its full budget.
44
- *
45
- * Known limitation: `startAttempt` cannot tell a self-inflicted process crash from an external SIGTERM
46
- * (no graceful drain), so a legitimately LONG turn interrupted by this many successive deploys is
47
- * dropped ("please ask again") as if it were poison. Accepted: catching SIGTERM to spare it would
48
- * reintroduce the drain the design refuses, and a turn outliving this many deploy cycles is an outlier
49
- * — raise this constant if such turns are expected. */
50
- const MAX_TURN_ATTEMPTS = 3;
42
+ export { defaultTelegramRoute, telegramEnvelope, telegramStop };
51
43
  /** Update body cap — Telegram updates are small JSON; 1 MiB is generous and guards a public endpoint. */
52
44
  const MAX_UPDATE_BYTES = 1 << 20;
53
45
  /** Constant-time compare so the secret-token check leaks no timing signal. */
@@ -77,9 +69,10 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
77
69
  throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
78
70
  }
79
71
  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.
72
+ // One getMe at startup: the bot's @username (for the default route's group @mention summon and for
73
+ // recognising an addressed `/stop`, only when not supplied) and the group-privacy flag — privacy
74
+ // mode off is required to receive the un-summoned group messages that feed the context buffer, so
75
+ // warn if it is on.
83
76
  let mentionName = botUsername;
84
77
  void callApi(apiBaseUrl, botToken, "getMe", {}).then((me) => {
85
78
  if (mentionName === undefined)
@@ -88,7 +81,11 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
88
81
  log.warn("[telegram] privacy mode is on: the bot only sees @mentions / replies / commands, so group " +
89
82
  "context (un-summoned messages) won't be captured. Disable it via @BotFather → /setprivacy.");
90
83
  }
91
- }, (e) => log.warn(`[telegram] getMe failed; @mention summon + privacy check skipped: ${String(e)}`));
84
+ },
85
+ // Name every capability the missing username costs, so "the bot ignores /stop@name" is
86
+ // diagnosable from this ONE line — the alternative, a warn per undecidable message, repeats a
87
+ // single startup fact on every update.
88
+ (e) => log.warn(`[telegram] getMe failed; @mention summon, addressed /stop, and the privacy check are skipped: ${String(e)}`));
92
89
  // A bot token is "<bot_id>:<secret>" — the bot's own id is knowable synchronously, so reply-to-bot
93
90
  // targeting is precise from the first update (no getMe race; getMe only resolves the @username).
94
91
  // Every real token parses; one that doesn't (a mock/test token) degrades visibly: reply summon stays
@@ -154,10 +151,10 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
154
151
  await notices.get(rec.id);
155
152
  notices.delete(rec.id);
156
153
  // Count this execution against the durable record (poison-turn ceiling) before running it again.
157
- const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
154
+ const decision = store.startAttempt(rec.id);
158
155
  if (decision === "exceeded") {
159
- // Started MAX_TURN_ATTEMPTS times without finishing — tell the asker (reusing its ⏳ notice if
160
- // any), drop it.
156
+ // Started the ceiling's worth of times without finishing (turn-store.ts MAX_TURN_ATTEMPTS)
157
+ // tell the asker (reusing its ⏳ notice if any), drop it.
161
158
  notifyDropped(rec);
162
159
  return;
163
160
  }
@@ -182,7 +179,7 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
182
179
  // Fold the un-summoned discussion since the last answered turn into the prompt; it is cleared
183
180
  // only when the turn COMPLETES (then it lives in the session).
184
181
  const { text: recent, consumed } = buffer.peek(rec.placeKey);
185
- const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
182
+ const prompt = `${discussionBlock(recent)}${rec.baseText}`;
186
183
  const buffered = collectAttachments(consumed, {
187
184
  files: new Set(rec.fileIds),
188
185
  images: new Set(rec.imageFileIds),
@@ -204,14 +201,7 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
204
201
  fileIds: rec.fileIds,
205
202
  },
206
203
  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);
204
+ }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.placeKey, consumed })), apiBaseUrl, botToken, target, formatError, rec.previewId);
215
205
  log.info(`[telegram] turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
216
206
  }
217
207
  catch (error) {
@@ -313,47 +303,45 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
313
303
  }
314
304
  return new Response(null, { status: 200 });
315
305
  }
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
- // Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the
327
- // run it stops. `/stop@otherbot` is not ours; a bare `/stop` always is. Awaited before the ACK:
328
- // dispatch + one sendMessage is fast, and a delivery failure logs instead of failing the webhook.
329
- const stopMatch = /^\/stop(?:@([A-Za-z0-9_]+))?$/i.exec(messageText(m).trim());
330
- if (stopMatch && (!stopMatch[1] || stopMatch[1].toLowerCase() === mentionName?.toLowerCase())) {
331
- const feedback = await dispatchStop(control, session, "[telegram]");
332
- const target = {
333
- chatId,
334
- threadId,
335
- replyTo: m.chat.type !== "private" && sameTarget ? m.message_id : undefined,
336
- };
337
- await sendMessage(apiBaseUrl, botToken, target, feedback, { html: false }).catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`));
338
- return new Response(null, { status: 200 });
339
- }
340
- const baseText = r.text ?? telegramEnvelope(m);
341
- const imageFileIds = extractImages(m);
342
- const fileIds = extractFiles(m);
343
- if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
344
- // Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
345
- submit({
346
- id: `${update.update_id}`,
347
- session,
348
- placeKey,
349
- baseText,
350
- chatId,
351
- threadId,
352
- replyTo: m.chat.type !== "private" && sameTarget ? m.message_id : undefined,
353
- imageFileIds,
354
- fileIds,
355
- }, true);
356
- }
306
+ const session = r.session ?? placeKey;
307
+ const chatId = r.chatId ?? m.chat.id;
308
+ // Reply to the summoning message in groups (threads the answer under the asker); a 1:1 DM needs no
309
+ // reply-quote. Only when the RESOLVED target is the message's own chat+thread: a route that
310
+ // redirects elsewhere must not carry a reply_parameters that resolves in the wrong place (fail, or
311
+ // quote a same-id message there). Compare VALUES, not whether the route touched the field a route
312
+ // that explicitly returns the same chat/thread still quotes.
313
+ const threadId = r.threadId ?? m.message_thread_id;
314
+ const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
315
+ const replyTo = m.chat.type !== "private" && sameTarget ? m.message_id : undefined;
316
+ // Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the
317
+ // run it stops. Awaited before the ACK: dispatch + one sendMessage is fast, and a delivery failure
318
+ // logs instead of failing the webhook. Read AFTER the route, on the route's own session: the route
319
+ // is both the gate (a route that ignores this chat must not have the bot abort or answer in it) and
320
+ // the session authority (a route that remaps `session` would otherwise have its stop abort a
321
+ // session nobody runs). The default route summons an ADDRESSED `/stop` in a group for this reason:
322
+ // the general slash-command refusal would leave it buffered as discussion while the run kept going.
323
+ // An unaddressed one is not a command at all (see telegramStop) and never reaches here.
324
+ if (telegramStop(update, { botUsername: mentionName, botId })) {
325
+ const feedback = await dispatchStop(control, session, "[telegram]");
326
+ await sendMessage(apiBaseUrl, botToken, { chatId, threadId, replyTo }, feedback, { html: false }).catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`));
327
+ return new Response(null, { status: 200 });
328
+ }
329
+ const baseText = r.text ?? telegramEnvelope(m);
330
+ const imageFileIds = extractImages(m);
331
+ const fileIds = extractFiles(m);
332
+ if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
333
+ // Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
334
+ submit({
335
+ id: `${update.update_id}`,
336
+ session,
337
+ placeKey,
338
+ baseText,
339
+ chatId,
340
+ threadId,
341
+ replyTo,
342
+ imageFileIds,
343
+ fileIds,
344
+ }, true);
357
345
  }
358
346
  return new Response(null, { status: 200 });
359
347
  };