@fastagent-sh/fastagent 0.28.0 → 0.30.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 (325) hide show
  1. package/README.md +10 -4
  2. package/dist/atomic-write.d.ts +4 -5
  3. package/dist/atomic-write.js +4 -5
  4. package/dist/authoring.d.ts +32 -15
  5. package/dist/authoring.js +147 -127
  6. package/dist/channels/agentcore-protocol.d.ts +8 -3
  7. package/dist/channels/agentcore-protocol.js +3 -1
  8. package/dist/channels/agentcore-service.d.ts +1 -0
  9. package/dist/channels/agentcore-service.js +14 -4
  10. package/dist/channels/agentcore.d.ts +9 -0
  11. package/dist/channels/agentcore.js +29 -4
  12. package/dist/channels/context.d.ts +2 -0
  13. package/dist/channels/context.js +5 -1
  14. package/dist/channels/feishu/feishu-api.d.ts +3 -1
  15. package/dist/channels/feishu/feishu-api.js +4 -0
  16. package/dist/channels/feishu/feishu.d.ts +0 -3
  17. package/dist/channels/feishu/feishu.js +10 -10
  18. package/dist/channels/feishu/register-app.d.ts +2 -0
  19. package/dist/channels/feishu/register-app.js +4 -6
  20. package/dist/channels/feishu/scaffold/channel.ts +32 -34
  21. package/dist/channels/feishu/scaffold/feishu-send.ts +5 -5
  22. package/dist/channels/feishu/scaffold/feishu-threads.ts +3 -3
  23. package/dist/channels/feishu/setup-mode.d.ts +2 -12
  24. package/dist/channels/feishu/setup-mode.js +2 -22
  25. package/dist/channels/feishu/setup.d.ts +12 -0
  26. package/dist/channels/feishu/setup.js +197 -0
  27. package/dist/channels/kit/abortable.d.ts +8 -0
  28. package/dist/channels/kit/abortable.js +22 -0
  29. package/dist/channels/lark/lark.d.ts +0 -3
  30. package/dist/channels/lark/lark.js +0 -5
  31. package/dist/channels/lark/scaffold/channel.ts +33 -35
  32. package/dist/channels/lark/scaffold/lark-send.ts +5 -5
  33. package/dist/channels/lark/scaffold/lark-threads.ts +3 -3
  34. package/dist/channels/lark/setup.d.ts +9 -0
  35. package/dist/channels/lark/setup.js +57 -0
  36. package/dist/channels/setup.d.ts +112 -0
  37. package/dist/channels/setup.js +31 -0
  38. package/dist/channels/slack/history.js +1 -1
  39. package/dist/channels/slack/manifest.d.ts +17 -7
  40. package/dist/channels/slack/manifest.js +12 -16
  41. package/dist/channels/slack/scaffold/channel.ts +44 -30
  42. package/dist/channels/slack/scaffold/slack-send.ts +8 -8
  43. package/dist/channels/slack/scaffold/slack-threads.ts +4 -4
  44. package/dist/channels/slack/setup.d.ts +7 -0
  45. package/dist/channels/slack/setup.js +119 -0
  46. package/dist/channels/slack/slack-api.d.ts +2 -0
  47. package/dist/channels/slack/slack-api.js +4 -0
  48. package/dist/channels/slack/slack.d.ts +21 -2
  49. package/dist/channels/slack/slack.js +398 -338
  50. package/dist/channels/slack/socket.d.ts +57 -0
  51. package/dist/channels/slack/socket.js +305 -0
  52. package/dist/channels/telegram/polling.d.ts +27 -0
  53. package/dist/channels/telegram/polling.js +105 -0
  54. package/dist/channels/telegram/scaffold/channel.ts +52 -32
  55. package/dist/channels/telegram/scaffold/telegram-send.ts +9 -9
  56. package/dist/channels/telegram/setup.d.ts +2 -0
  57. package/dist/channels/telegram/setup.js +42 -0
  58. package/dist/channels/telegram/telegram-api.d.ts +27 -1
  59. package/dist/channels/telegram/telegram-api.js +7 -6
  60. package/dist/channels/telegram/telegram.d.ts +11 -3
  61. package/dist/channels/telegram/telegram.js +184 -157
  62. package/dist/channels/wait-health.js +1 -1
  63. package/dist/channels/webhook-evidence.d.ts +11 -0
  64. package/dist/channels/webhook-evidence.js +71 -0
  65. package/dist/channels/webhooks.d.ts +37 -0
  66. package/dist/channels/webhooks.js +70 -0
  67. package/dist/child-env.d.ts +16 -0
  68. package/dist/child-env.js +30 -0
  69. package/dist/cli/box-login.js +4 -1
  70. package/dist/cli/channel-app.d.ts +3 -0
  71. package/dist/cli/channel-app.js +35 -0
  72. package/dist/cli/channel-setup.d.ts +38 -0
  73. package/dist/cli/channel-setup.js +195 -0
  74. package/dist/cli/commands/add.d.ts +6 -14
  75. package/dist/cli/commands/add.js +45 -310
  76. package/dist/cli/commands/auth.js +8 -2
  77. package/dist/cli/commands/build.d.ts +5 -0
  78. package/dist/cli/commands/build.js +16 -0
  79. package/dist/cli/commands/channels.d.ts +19 -0
  80. package/dist/cli/commands/channels.js +230 -0
  81. package/dist/cli/commands/content.d.ts +7 -3
  82. package/dist/cli/commands/content.js +16 -10
  83. package/dist/cli/commands/deploy/agentcore.js +55 -23
  84. package/dist/cli/commands/deploy/channel-apps.d.ts +17 -0
  85. package/dist/cli/commands/deploy/channel-apps.js +30 -0
  86. package/dist/cli/commands/deploy/docker.js +32 -65
  87. package/dist/cli/commands/deploy/fly.js +28 -12
  88. package/dist/cli/commands/deploy/railway.js +38 -13
  89. package/dist/cli/commands/deploy/secrets-step.d.ts +19 -0
  90. package/dist/cli/commands/deploy/secrets-step.js +66 -0
  91. package/dist/cli/commands/deploy/shared.d.ts +16 -5
  92. package/dist/cli/commands/deploy/shared.js +25 -8
  93. package/dist/cli/commands/deploy.js +53 -67
  94. package/dist/cli/commands/dev.d.ts +2 -0
  95. package/dist/cli/commands/dev.js +15 -3
  96. package/dist/cli/commands/env.js +1 -1
  97. package/dist/cli/commands/info.d.ts +2 -0
  98. package/dist/cli/commands/info.js +63 -24
  99. package/dist/cli/commands/init.js +3 -1
  100. package/dist/cli/commands/invoke.d.ts +2 -0
  101. package/dist/cli/commands/invoke.js +20 -7
  102. package/dist/cli/commands/login.d.ts +0 -7
  103. package/dist/cli/commands/login.js +22 -10
  104. package/dist/cli/commands/logout.js +1 -1
  105. package/dist/cli/commands/mcp.d.ts +12 -0
  106. package/dist/cli/commands/mcp.js +164 -0
  107. package/dist/cli/commands/models.js +3 -2
  108. package/dist/cli/commands/schedules.d.ts +4 -2
  109. package/dist/cli/commands/schedules.js +14 -10
  110. package/dist/cli/commands/secrets.d.ts +14 -0
  111. package/dist/cli/commands/secrets.js +213 -0
  112. package/dist/cli/commands/start.d.ts +14 -14
  113. package/dist/cli/commands/start.js +46 -60
  114. package/dist/cli/content-view.d.ts +4 -6
  115. package/dist/cli/content-view.js +5 -11
  116. package/dist/cli/first-run-channels.d.ts +3 -0
  117. package/dist/cli/first-run-channels.js +63 -0
  118. package/dist/cli/instance.d.ts +41 -0
  119. package/dist/cli/instance.js +155 -0
  120. package/dist/cli/invoke-stream.d.ts +5 -2
  121. package/dist/cli/invoke-stream.js +7 -2
  122. package/dist/cli/program.js +339 -108
  123. package/dist/cli/serve.d.ts +4 -5
  124. package/dist/cli/serve.js +4 -7
  125. package/dist/cli/shared.d.ts +11 -2
  126. package/dist/cli/shared.js +32 -19
  127. package/dist/cli/ticket.js +50 -6
  128. package/dist/content/declare.d.ts +31 -37
  129. package/dist/content/declare.js +59 -60
  130. package/dist/content/file.d.ts +23 -12
  131. package/dist/content/file.js +48 -35
  132. package/dist/content/git.d.ts +0 -10
  133. package/dist/content/git.js +5 -39
  134. package/dist/content/resolve.d.ts +18 -22
  135. package/dist/content/resolve.js +32 -66
  136. package/dist/content/source.d.ts +7 -8
  137. package/dist/content/source.js +10 -28
  138. package/dist/credentials-page.d.ts +26 -0
  139. package/dist/credentials-page.js +185 -0
  140. package/dist/declared-secrets.d.ts +10 -3
  141. package/dist/declared-secrets.js +8 -4
  142. package/dist/deploy/agentcore/destroy.d.ts +4 -1
  143. package/dist/deploy/agentcore/destroy.js +26 -2
  144. package/dist/deploy/agentcore/forwarder.js +1 -1
  145. package/dist/deploy/agentcore/plan.d.ts +7 -21
  146. package/dist/deploy/agentcore/plan.js +47 -49
  147. package/dist/deploy/agentcore/run.d.ts +18 -10
  148. package/dist/deploy/agentcore/run.js +93 -70
  149. package/dist/deploy/agentcore/runtime-secrets.d.ts +7 -0
  150. package/dist/deploy/agentcore/runtime-secrets.js +108 -0
  151. package/dist/deploy/agentcore/shell.js +12 -19
  152. package/dist/deploy/agentcore/sigv4.d.ts +7 -0
  153. package/dist/deploy/agentcore/sigv4.js +15 -0
  154. package/dist/deploy/agentcore/slr.d.ts +18 -0
  155. package/dist/deploy/agentcore/slr.js +27 -0
  156. package/dist/deploy/agentcore/store.d.ts +7 -0
  157. package/dist/deploy/agentcore/store.js +115 -0
  158. package/dist/deploy/api.d.ts +14 -14
  159. package/dist/deploy/api.js +26 -24
  160. package/dist/deploy/box-shell.d.ts +3 -1
  161. package/dist/deploy/build-context.d.ts +4 -4
  162. package/dist/deploy/build-context.js +11 -11
  163. package/dist/deploy/channel-ingress.d.ts +35 -9
  164. package/dist/deploy/channel-ingress.js +119 -22
  165. package/dist/deploy/container.d.ts +35 -10
  166. package/dist/deploy/container.js +80 -39
  167. package/dist/deploy/delivered-text.d.ts +13 -0
  168. package/dist/deploy/delivered-text.js +86 -0
  169. package/dist/deploy/docker/plan.d.ts +7 -8
  170. package/dist/deploy/docker/plan.js +31 -52
  171. package/dist/deploy/docker/run.d.ts +18 -43
  172. package/dist/deploy/docker/run.js +57 -79
  173. package/dist/deploy/file-store.d.ts +2 -0
  174. package/dist/deploy/file-store.js +17 -0
  175. package/dist/deploy/fly/plan.d.ts +3 -2
  176. package/dist/deploy/fly/plan.js +20 -9
  177. package/dist/deploy/fly/run.d.ts +18 -22
  178. package/dist/deploy/fly/run.js +25 -44
  179. package/dist/deploy/fly/store.d.ts +12 -0
  180. package/dist/deploy/fly/store.js +83 -0
  181. package/dist/deploy/hosts.d.ts +1 -2
  182. package/dist/deploy/hosts.js +1 -2
  183. package/dist/deploy/preflight.d.ts +18 -5
  184. package/dist/deploy/preflight.js +135 -63
  185. package/dist/deploy/railway/ignore.d.ts +8 -0
  186. package/dist/deploy/railway/ignore.js +57 -0
  187. package/dist/deploy/railway/plan.d.ts +1 -2
  188. package/dist/deploy/railway/plan.js +8 -6
  189. package/dist/deploy/railway/run.d.ts +21 -15
  190. package/dist/deploy/railway/run.js +32 -78
  191. package/dist/deploy/railway/store.d.ts +29 -0
  192. package/dist/deploy/railway/store.js +143 -0
  193. package/dist/deploy/runner.d.ts +5 -2
  194. package/dist/deploy/runner.js +4 -3
  195. package/dist/deploy/secret-store.d.ts +63 -0
  196. package/dist/deploy/secret-store.js +98 -0
  197. package/dist/deploy/secrets.d.ts +29 -46
  198. package/dist/deploy/secrets.js +49 -122
  199. package/dist/deploy/workspace.d.ts +6 -12
  200. package/dist/deploy/workspace.js +18 -94
  201. package/dist/dev-supervisor.js +6 -7
  202. package/dist/environment/declare.js +3 -2
  203. package/dist/environment/mise.d.ts +3 -1
  204. package/dist/environment/mise.js +4 -2
  205. package/dist/feishu.d.ts +1 -1
  206. package/dist/feishu.js +1 -1
  207. package/dist/harnesses/models/catalog.d.ts +1 -0
  208. package/dist/harnesses/models/catalog.js +1 -2
  209. package/dist/harnesses/models/environment.d.ts +7 -2
  210. package/dist/harnesses/models/environment.js +9 -5
  211. package/dist/harnesses/models/host.d.ts +0 -7
  212. package/dist/harnesses/models/host.js +10 -1
  213. package/dist/harnesses/models/login.d.ts +0 -8
  214. package/dist/harnesses/models/login.js +5 -4
  215. package/dist/harnesses/models/models.d.ts +7 -2
  216. package/dist/harnesses/models/models.js +28 -4
  217. package/dist/harnesses/pi-durable/agent.js +59 -38
  218. package/dist/harnesses/pi-durable/build.d.ts +12 -0
  219. package/dist/harnesses/pi-durable/build.js +270 -0
  220. package/dist/harnesses/pi-durable/built.d.ts +24 -0
  221. package/dist/harnesses/pi-durable/built.js +136 -0
  222. package/dist/harnesses/pi-durable/config.d.ts +81 -0
  223. package/dist/harnesses/pi-durable/config.js +266 -0
  224. package/dist/harnesses/pi-durable/define.d.ts +63 -13
  225. package/dist/harnesses/pi-durable/define.js +23 -2
  226. package/dist/harnesses/pi-durable/definition.d.ts +72 -0
  227. package/dist/harnesses/pi-durable/definition.js +143 -0
  228. package/dist/harnesses/pi-durable/events.d.ts +4 -3
  229. package/dist/harnesses/pi-durable/events.js +179 -136
  230. package/dist/harnesses/pi-durable/execution-env.d.ts +17 -0
  231. package/dist/harnesses/pi-durable/execution-env.js +11 -0
  232. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  233. package/dist/harnesses/pi-durable/mcp.js +32 -0
  234. package/dist/harnesses/pi-durable/open.d.ts +36 -38
  235. package/dist/harnesses/pi-durable/open.js +97 -150
  236. package/dist/harnesses/pi-durable/reads.d.ts +0 -1
  237. package/dist/harnesses/pi-durable/reads.js +1 -3
  238. package/dist/harnesses/pi-durable/registry.d.ts +4 -6
  239. package/dist/harnesses/pi-durable/registry.js +29 -26
  240. package/dist/harnesses/pi-durable/release.d.ts +2 -2
  241. package/dist/harnesses/pi-durable/release.js +10 -7
  242. package/dist/harnesses/pi-durable/serve.d.ts +95 -15
  243. package/dist/harnesses/pi-durable/serve.js +151 -59
  244. package/dist/harnesses/pi-durable/sessions.js +15 -12
  245. package/dist/harnesses/pi-durable/text-sources.d.ts +30 -0
  246. package/dist/harnesses/pi-durable/text-sources.js +55 -0
  247. package/dist/harnesses/pi-durable/text.d.ts +16 -4
  248. package/dist/harnesses/pi-durable/text.js +115 -59
  249. package/dist/harnesses/pi-durable/wake.js +8 -8
  250. package/dist/http/client.js +9 -3
  251. package/dist/http/server.d.ts +1 -1
  252. package/dist/http/server.js +1 -1
  253. package/dist/index.d.ts +11 -4
  254. package/dist/index.js +8 -1
  255. package/dist/instance-name.d.ts +7 -0
  256. package/dist/instance-name.js +26 -0
  257. package/dist/lark.d.ts +1 -1
  258. package/dist/lark.js +1 -1
  259. package/dist/locked-file.d.ts +2 -2
  260. package/dist/locked-file.js +8 -3
  261. package/dist/mcp/auth.d.ts +22 -0
  262. package/dist/mcp/auth.js +180 -0
  263. package/dist/mcp/client.d.ts +15 -0
  264. package/dist/mcp/client.js +179 -0
  265. package/dist/mcp/config.d.ts +86 -0
  266. package/dist/mcp/config.js +225 -0
  267. package/dist/mcp/grants.d.ts +4 -0
  268. package/dist/mcp/grants.js +132 -0
  269. package/dist/mcp/host.d.ts +19 -0
  270. package/dist/mcp/host.js +128 -0
  271. package/dist/mcp/network.d.ts +12 -0
  272. package/dist/mcp/network.js +150 -0
  273. package/dist/mcp/sign-in.d.ts +2 -0
  274. package/dist/mcp/sign-in.js +204 -0
  275. package/dist/mcp-api.d.ts +107 -0
  276. package/dist/mcp-api.js +10 -0
  277. package/dist/paths.d.ts +22 -8
  278. package/dist/paths.js +44 -11
  279. package/dist/runtime-websocket.d.ts +14 -0
  280. package/dist/runtime-websocket.js +39 -0
  281. package/dist/scaffold/add-channel.d.ts +9 -16
  282. package/dist/scaffold/add-channel.js +13 -83
  283. package/dist/scaffold/templates/env.example +4 -5
  284. package/dist/scaffold/templates/fastagent.config.ts +20 -14
  285. package/dist/scaffold/templates/gitignore +9 -6
  286. package/dist/scaffold/templates.d.ts +6 -2
  287. package/dist/scaffold/templates.js +7 -2
  288. package/dist/scaffold/vendor-skill.js +4 -2
  289. package/dist/service.d.ts +44 -6
  290. package/dist/service.js +110 -10
  291. package/dist/serving-command.d.ts +10 -0
  292. package/dist/serving-command.js +1 -0
  293. package/dist/setup-page.d.ts +51 -0
  294. package/dist/setup-page.js +149 -0
  295. package/dist/slack.d.ts +1 -1
  296. package/dist/slack.js +1 -1
  297. package/dist/strip-edges.d.ts +10 -0
  298. package/dist/strip-edges.js +22 -0
  299. package/dist/telegram.d.ts +1 -1
  300. package/dist/telegram.js +1 -1
  301. package/dist/tunnel.d.ts +0 -1
  302. package/dist/tunnel.js +2 -8
  303. package/dist/value-file.d.ts +12 -6
  304. package/dist/value-file.js +36 -25
  305. package/dist/version.d.ts +7 -1
  306. package/dist/version.js +9 -3
  307. package/package.json +8 -6
  308. package/dist/channels/lark/onboard.d.ts +0 -36
  309. package/dist/channels/lark/onboard.js +0 -56
  310. package/dist/channels/slack/config-api.d.ts +0 -69
  311. package/dist/channels/slack/config-api.js +0 -162
  312. package/dist/channels/slack/onboard.d.ts +0 -40
  313. package/dist/channels/slack/onboard.js +0 -136
  314. package/dist/channels/slack/onboarding-state.d.ts +0 -31
  315. package/dist/channels/slack/onboarding-state.js +0 -64
  316. package/dist/channels/slack/register-webhook.d.ts +0 -12
  317. package/dist/channels/slack/register-webhook.js +0 -67
  318. package/dist/channels/slack/setup-server.d.ts +0 -32
  319. package/dist/channels/slack/setup-server.js +0 -201
  320. package/dist/cli/add-feishu.d.ts +0 -50
  321. package/dist/cli/add-feishu.js +0 -367
  322. package/dist/cli/add-slack.d.ts +0 -18
  323. package/dist/cli/add-slack.js +0 -216
  324. package/dist/harnesses/pi-durable/parked.d.ts +0 -18
  325. package/dist/harnesses/pi-durable/parked.js +0 -27
@@ -0,0 +1,42 @@
1
+ /** What a Telegram bot's setup asks for (channels/setup.ts): its token from BotFather, and for a webhook a secret. */
2
+ import { randomBytes } from "node:crypto";
3
+ import { SetupRefused, pasteOne } from "../setup.js";
4
+ import { TelegramApiError, callApi } from "./telegram-api.js";
5
+ const TOKEN = /^\d+:[A-Za-z0-9_-]{30,}$/;
6
+ export const telegramSetup = ({ way }) => ({
7
+ finish: () => [],
8
+ values: [
9
+ pasteOne({
10
+ name: "TELEGRAM_BOT_TOKEN",
11
+ label: "Bot token",
12
+ guide: {
13
+ instructions: "Message @BotFather, send /newbot, follow its questions, and copy the token it gives you.",
14
+ url: "https://t.me/BotFather",
15
+ },
16
+ async check(value) {
17
+ if (!TOKEN.test(value)) {
18
+ throw new SetupRefused("That is not a bot token.", {
19
+ fix: "BotFather's token looks like 123456789:AAH…, a number, a colon and a long string.",
20
+ });
21
+ }
22
+ try {
23
+ const me = await callApi("https://api.telegram.org", value, "getMe", {});
24
+ return me.username ? `bot @${me.username}` : "bot";
25
+ }
26
+ catch (error) {
27
+ if (error instanceof TelegramApiError && (error.status === 401 || error.status === 404)) {
28
+ throw new SetupRefused("Telegram does not accept this token.", {
29
+ fix: "Copy it again from BotFather (/mybots → the bot → API Token).",
30
+ why: error.message,
31
+ });
32
+ }
33
+ throw error;
34
+ }
35
+ },
36
+ }),
37
+ // Only a webhook has an inbound request to verify: a polling bot asks Telegram for its updates.
38
+ ...(way === "webhook"
39
+ ? [{ kind: "make", name: "TELEGRAM_SECRET_TOKEN", make: () => randomBytes(24).toString("hex") }]
40
+ : []),
41
+ ],
42
+ });
@@ -1,5 +1,6 @@
1
1
  import type { ImageAttachment as ImageRef } from "../kit/invoke-turn-kit.ts";
2
2
  import type { CallOptions } from "../kit/transport.ts";
3
+ import type { TelegramUpdate } from "./parse.ts";
3
4
  /** Telegram's hard text limit per message. */
4
5
  export declare const TELEGRAM_MAX_TEXT = 4096;
5
6
  /** Where a reply goes: a chat, optionally a thread (Threaded Mode), optionally replying to a message. */
@@ -41,6 +42,31 @@ interface Api {
41
42
  file_size?: number;
42
43
  };
43
44
  setWebhook: unknown;
45
+ deleteWebhook: unknown;
46
+ getWebhookInfo: {
47
+ url?: string;
48
+ };
49
+ getUpdates: TelegramUpdate[];
50
+ }
51
+ /** What a Telegram call may be told beyond the shared budget: a longer wait (a long poll) and a way to be cut short. */
52
+ export interface TelegramCallOptions extends CallOptions {
53
+ /** Replaces the per-attempt timeout; a long poll must outlast the wait it asks Telegram for. */
54
+ readonly timeoutMs?: number;
55
+ /** Ends the call early, as a failure the caller reads as its own cancellation. */
56
+ readonly signal?: AbortSignal;
57
+ }
58
+ /** A named Bot API failure. `status` 0 = the transport itself failed (network error / timeout) before
59
+ * any HTTP status existed; otherwise the HTTP status with Telegram's own description (or a note that
60
+ * the body carried no usable answer). A typed class (not a plain Error) because callers tell failures apart:
61
+ * sendMessage's parse-mode fallback, editMessageText's "not modified" swallow, and the long poll's reading of a
62
+ * 401 or a 409 — each does `instanceof` + reads `.status` / `.description`. */
63
+ export declare class TelegramApiError extends Error {
64
+ readonly method: string;
65
+ readonly status: number;
66
+ readonly description: string;
67
+ constructor(method: string, status: number, description: string, options?: {
68
+ cause?: unknown;
69
+ });
44
70
  }
45
71
  /**
46
72
  * The pipeline: one Bot API call, carrying every transport invariant (see the module header). Returns
@@ -48,7 +74,7 @@ interface Api {
48
74
  */
49
75
  export declare function callApi<M extends keyof Api>(api: string, botToken: string, method: M,
50
76
  /** JSON params, or a multipart form for an upload (a file's bytes; its own, longer timeout). */
51
- params: Record<string, unknown> | FormData, opts?: CallOptions): Promise<Api[M]>;
77
+ params: Record<string, unknown> | FormData, opts?: TelegramCallOptions): Promise<Api[M]>;
52
78
  /**
53
79
  * Split text into ≤4096-char chunks (Telegram's limit), preferring a newline boundary. When `html`, it is
54
80
  * tag-aware: a tag that would SPAN a boundary is CLOSED at the chunk's end and REOPENED (attributes and
@@ -37,11 +37,10 @@ const RETRIES = 3;
37
37
  const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
38
38
  /** A named Bot API failure. `status` 0 = the transport itself failed (network error / timeout) before
39
39
  * any HTTP status existed; otherwise the HTTP status with Telegram's own description (or a note that
40
- * the body carried no usable answer). Module-private: no EXTERNAL caller matches on the type; it
41
- * stays a typed class (not a plain Error) because this module's own retry logic — sendMessage's
42
- * parse-mode fallback, editMessageText's "not modified" swallow — does `instanceof` + reads
43
- * `.description`. */
44
- class TelegramApiError extends Error {
40
+ * the body carried no usable answer). A typed class (not a plain Error) because callers tell failures apart:
41
+ * sendMessage's parse-mode fallback, editMessageText's "not modified" swallow, and the long poll's reading of a
42
+ * 401 or a 409 — each does `instanceof` + reads `.status` / `.description`. */
43
+ export class TelegramApiError extends Error {
45
44
  method;
46
45
  status;
47
46
  description;
@@ -57,6 +56,8 @@ class TelegramApiError extends Error {
57
56
  this.description = description;
58
57
  }
59
58
  }
59
+ /** The per-attempt timeout, also ended by the caller's own signal when it gave one. */
60
+ const withCancel = (timeout, cancel) => cancel === undefined ? timeout : AbortSignal.any([timeout, cancel]);
60
61
  /** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers. */
61
62
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
63
  /**
@@ -77,7 +78,7 @@ params, opts = {}) {
77
78
  ...(upload
78
79
  ? { body: params }
79
80
  : { headers: { "content-type": "application/json" }, body: JSON.stringify(params) }),
80
- signal: AbortSignal.timeout(upload ? DOWNLOAD_TIMEOUT_MS : API_TIMEOUT_MS),
81
+ signal: withCancel(AbortSignal.timeout(opts.timeoutMs ?? (upload ? DOWNLOAD_TIMEOUT_MS : API_TIMEOUT_MS)), opts.signal),
81
82
  });
82
83
  raw = await res.text(); // the body read shares the timeout — a mid-body stall is a transport failure too
83
84
  }
@@ -1,4 +1,4 @@
1
- import type { ChannelModule } from "../../channel.ts";
1
+ import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
2
2
  import { type TelegramMessage, type TelegramRoute, type TelegramUpdate, defaultTelegramRoute, telegramEnvelope, telegramStop } from "./parse.ts";
3
3
  import { type TelegramFailure } from "./preview.ts";
4
4
  export { defaultTelegramRoute, telegramEnvelope, telegramStop };
@@ -17,5 +17,13 @@ export interface TelegramChannelOptions {
17
17
  /** Bot API base, for tests. */
18
18
  apiBaseUrl?: string;
19
19
  }
20
- /** Build a Telegram bot channel: policy options in, a {@link ChannelModule} out. */
21
- export declare function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl, }: TelegramChannelOptions): ChannelModule;
20
+ /** What a channel that polls is given: the webhook one's options without the secret that verifies a webhook. */
21
+ export type TelegramPollingChannelOptions = Omit<TelegramChannelOptions, "secretToken">;
22
+ /** Build a Telegram bot channel that receives by webhook: policy options in, a {@link ChannelModule} out. */
23
+ export declare function telegramChannel(options: TelegramChannelOptions): ChannelModule;
24
+ /**
25
+ * Build a Telegram bot channel that receives by long polling: the process asks Telegram for updates, so no public URL
26
+ * and no webhook secret are involved. It takes the webhook away from the bot first (`getUpdates` does not work while
27
+ * one is set), so a bot is polled by one process or reached by one webhook, never both.
28
+ */
29
+ export declare function telegramPollingChannel(options: TelegramPollingChannelOptions): LongConnectionChannelModule;
@@ -19,109 +19,181 @@ import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
19
19
  import { createTurnRunner } from "../kit/turn-runner.js";
20
20
  import { discussionBlock } from "../kit/context-buffer.js";
21
21
  import { portJoin } from "../../effect-port.js";
22
+ import { pollTelegramUpdates } from "./polling.js";
22
23
  import { createTurnStore } from "./turn-store.js";
23
24
  // Re-export the public surface authored elsewhere, so `@fastagent-sh/fastagent/telegram` keeps one entry point.
24
25
  export { defaultTelegramRoute, telegramEnvelope, telegramStop };
26
+ const TELEGRAM_API = "https://api.telegram.org";
25
27
  /** Update body cap — Telegram updates are small JSON; 1 MiB is generous and guards a public endpoint. */
26
28
  const MAX_UPDATE_BYTES = 1 << 20;
27
- /** Build a Telegram bot channel: policy options in, a {@link ChannelModule} out. */
28
- export function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl = "https://api.telegram.org", }) {
29
- return ({ agent, sessionFor, recordedSession, stateDir: stateHome }) => {
30
- // Validate at activation so deploy may inspect the module shape before secrets are provisioned.
31
- if (!secretToken) {
32
- throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
33
- }
34
- if (!botToken) {
35
- throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
29
+ /** The channel's state and policy, over the context it is mounted in: everything but how updates arrive. */
30
+ function createTelegramRuntime({ botToken, route, onError, botUsername, apiBaseUrl }, { agent, sessionFor, recordedSession, stateDir: stateHome }) {
31
+ // Validate at activation so deploy may inspect the module shape before secrets are provisioned.
32
+ if (!botToken) {
33
+ throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
34
+ }
35
+ const formatError = onError ?? defaultErrorMessage;
36
+ // One getMe at startup: the bot's @username (for the default route's group @mention summon and for recognising an
37
+ // addressed `/stop`, only when not supplied) and the group-privacy flag — privacy mode off is required to receive
38
+ // the un-summoned group messages that feed the context buffer, so warn if it is on.
39
+ let mentionName = botUsername;
40
+ void callApi(apiBaseUrl, botToken, "getMe", {}).then((me) => {
41
+ if (mentionName === undefined)
42
+ mentionName = me.username;
43
+ if (me.can_read_all_group_messages === false) {
44
+ log.warn("[telegram] privacy mode is on: the bot only sees @mentions / replies / commands, so group " +
45
+ "context (un-summoned messages) won't be captured. Disable it via @BotFather → /setprivacy.");
36
46
  }
37
- const formatError = onError ?? defaultErrorMessage;
38
- // One getMe at startup: the bot's @username (for the default route's group @mention summon and for recognising an
39
- // addressed `/stop`, only when not supplied) and the group-privacy flag — privacy mode off is required to receive
40
- // the un-summoned group messages that feed the context buffer, so warn if it is on.
41
- let mentionName = botUsername;
42
- void callApi(apiBaseUrl, botToken, "getMe", {}).then((me) => {
43
- if (mentionName === undefined)
44
- mentionName = me.username;
45
- if (me.can_read_all_group_messages === false) {
46
- log.warn("[telegram] privacy mode is on: the bot only sees @mentions / replies / commands, so group " +
47
- "context (un-summoned messages) won't be captured. Disable it via @BotFather → /setprivacy.");
47
+ },
48
+ // Name every capability the missing username costs, so "the bot ignores /stop@name" is diagnosable from this
49
+ // ONE line.
50
+ (e) => log.warn(`[telegram] getMe failed; @mention summon, addressed /stop, and the privacy check are skipped: ${String(e)}`));
51
+ // A bot token is "<bot_id>:<secret>" — the bot's own id is knowable synchronously, so reply-to-bot targeting is
52
+ // precise from the first update (no getMe race; getMe only resolves the @username).
53
+ const tokenId = Number(botToken.split(":")[0]);
54
+ const botId = Number.isSafeInteger(tokenId) && tokenId > 0 ? tokenId : undefined;
55
+ if (botId === undefined) {
56
+ log.warn("[telegram] bot token has no parseable bot id — reply-to-bot summon disabled until getMe resolves");
57
+ }
58
+ const decide = route ?? ((update) => defaultTelegramRoute(update, { botUsername: mentionName, botId }));
59
+ const buffer = createContextBuffer(join(stateHome, "buffers.json"));
60
+ // The send tool rides this channel's token, and what it sends lands in this buffer (shared-api.ts says why).
61
+ registerTelegramTransport({
62
+ apiBaseUrl,
63
+ botToken,
64
+ record: (placeKey, entry) => buffer.push(placeKey, entry),
65
+ });
66
+ // Durable turn intent (L1): persist an accepted turn pre-ACK, remove it when the turn ends; a crash leaves it for
67
+ // replay on the next start.
68
+ const store = createTurnStore(join(stateHome, "turns.json"));
69
+ const sideTasks = createTaskTracker("[telegram]");
70
+ const targetOf = (r) => ({ chatId: r.chatId, threadId: r.threadId, replyTo: r.replyTo });
71
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not just an
72
+ // operator log line.
73
+ const notifyDropped = (r) => {
74
+ const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
75
+ const sent = r.previewId !== undefined
76
+ ? editMessageText(apiBaseUrl, botToken, targetOf(r), r.previewId, body, { html: false })
77
+ : sendMessage(apiBaseUrl, botToken, targetOf(r), body, { html: false }).then(() => { });
78
+ void sent.catch((e) => log.warn(`[telegram] could not notify a dropped turn (conversation=${r.conversation}): ${String(e)}`));
79
+ };
80
+ const runner = createTurnRunner({
81
+ label: "[telegram]",
82
+ store,
83
+ discussion: buffer,
84
+ toStored: ({ previewId: _live, ...intent }) => ({ ...intent, attempts: 0 }),
85
+ fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, previewId: undefined }),
86
+ discussionKey: (rec) => rec.placeKey,
87
+ where: (rec) => `chat=${rec.chatId}${rec.threadId !== undefined ? ` thread=${rec.threadId}` : ""}`,
88
+ // Queue feedback: when this session already has a turn running/queued, a silent wait reads as "the bot ignored
89
+ // me" once the current turn runs long.
90
+ onQueuedBehind: (rec) => ({
91
+ done: sendMessage(apiBaseUrl, botToken, targetOf(rec), "⏳ Queued — I’ll start once the current task finishes.", {
92
+ html: false,
93
+ }).then((id) => {
94
+ if (id !== undefined)
95
+ rec.previewId = id;
96
+ }, (e) => log.warn(`[telegram] queue notice failed (the turn still runs): ${String(e)}`)),
97
+ }),
98
+ // Its ⏳ notice (if any) now falsely reads "Queued": delete it best-effort.
99
+ onDeferred: (rec) => {
100
+ if (rec.previewId !== undefined) {
101
+ void callApi(apiBaseUrl, botToken, "deleteMessage", {
102
+ chat_id: rec.chatId,
103
+ message_id: rec.previewId,
104
+ }).catch(() => { });
48
105
  }
49
- },
50
- // Name every capability the missing username costs, so "the bot ignores /stop@name" is diagnosable from this
51
- // ONE line.
52
- (e) => log.warn(`[telegram] getMe failed; @mention summon, addressed /stop, and the privacy check are skipped: ${String(e)}`));
53
- // A bot token is "<bot_id>:<secret>" — the bot's own id is knowable synchronously, so reply-to-bot targeting is
54
- // precise from the first update (no getMe race; getMe only resolves the @username).
55
- const tokenId = Number(botToken.split(":")[0]);
56
- const botId = Number.isSafeInteger(tokenId) && tokenId > 0 ? tokenId : undefined;
57
- if (botId === undefined) {
58
- log.warn("[telegram] bot token has no parseable bot id — reply-to-bot summon disabled until getMe resolves");
59
- }
60
- const decide = route ?? ((update) => defaultTelegramRoute(update, { botUsername: mentionName, botId }));
61
- const buffer = createContextBuffer(join(stateHome, "buffers.json"));
62
- // The send tool rides this channel's token, and what it sends lands in this buffer (shared-api.ts says why).
63
- registerTelegramTransport({
64
- apiBaseUrl,
65
- botToken,
66
- record: (placeKey, entry) => buffer.push(placeKey, entry),
67
- });
68
- // Durable turn intent (L1): persist an accepted turn pre-ACK, remove it when the turn ends; a crash leaves it for
69
- // replay on the next start.
70
- const store = createTurnStore(join(stateHome, "turns.json"));
71
- const sideTasks = createTaskTracker("[telegram]");
72
- const targetOf = (r) => ({ chatId: r.chatId, threadId: r.threadId, replyTo: r.replyTo });
73
- // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not just an
74
- // operator log line.
75
- const notifyDropped = (r) => {
76
- const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
77
- const sent = r.previewId !== undefined
78
- ? editMessageText(apiBaseUrl, botToken, targetOf(r), r.previewId, body, { html: false })
79
- : sendMessage(apiBaseUrl, botToken, targetOf(r), body, { html: false }).then(() => { });
80
- void sent.catch((e) => log.warn(`[telegram] could not notify a dropped turn (conversation=${r.conversation}): ${String(e)}`));
81
- };
82
- const runner = createTurnRunner({
83
- label: "[telegram]",
84
- store,
85
- discussion: buffer,
86
- toStored: ({ previewId: _live, ...intent }) => ({ ...intent, attempts: 0 }),
87
- fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, previewId: undefined }),
88
- discussionKey: (rec) => rec.placeKey,
89
- where: (rec) => `chat=${rec.chatId}${rec.threadId !== undefined ? ` thread=${rec.threadId}` : ""}`,
90
- // Queue feedback: when this session already has a turn running/queued, a silent wait reads as "the bot ignored
91
- // me" once the current turn runs long.
92
- onQueuedBehind: (rec) => ({
93
- done: sendMessage(apiBaseUrl, botToken, targetOf(rec), "⏳ Queued — I’ll start once the current task finishes.", {
94
- html: false,
95
- }).then((id) => {
96
- if (id !== undefined)
97
- rec.previewId = id;
98
- }, (e) => log.warn(`[telegram] queue notice failed (the turn still runs): ${String(e)}`)),
106
+ },
107
+ notifyDropped,
108
+ // `rec.previewId`: the ⏳ notice THIS process put up if the record waited behind another turn.
109
+ deliverAnswer: (rec, answer) => portJoin(() => deliverTelegramAnswer(apiBaseUrl, botToken, targetOf(rec), answer, rec.previewId)),
110
+ execute: (rec, discussion, onAnswered) => telegramReply(telegramTurnStream(agent, {
111
+ session: () => sessionFor(rec.conversation),
112
+ key: rec.id,
113
+ text: `${discussionBlock(discussion.text)}${rec.baseText}`,
114
+ }, { api: apiBaseUrl, botToken, chatId: rec.chatId, filesDir: attachmentsDir(stateHome) }, {
115
+ primary: { imageFileIds: rec.imageFileIds, fileIds: rec.fileIds },
116
+ buffered: collectAttachments(discussion.consumed, {
117
+ files: new Set(rec.fileIds),
118
+ images: new Set(rec.imageFileIds),
99
119
  }),
100
- // Its ⏳ notice (if any) now falsely reads "Queued": delete it best-effort.
101
- onDeferred: (rec) => {
102
- if (rec.previewId !== undefined) {
103
- void callApi(apiBaseUrl, botToken, "deleteMessage", {
104
- chat_id: rec.chatId,
105
- message_id: rec.previewId,
106
- }).catch(() => { });
107
- }
108
- },
109
- notifyDropped,
110
- // `rec.previewId`: the ⏳ notice THIS process put up if the record waited behind another turn.
111
- deliverAnswer: (rec, answer) => portJoin(() => deliverTelegramAnswer(apiBaseUrl, botToken, targetOf(rec), answer, rec.previewId)),
112
- execute: (rec, discussion, onAnswered) => telegramReply(telegramTurnStream(agent, {
113
- session: () => sessionFor(rec.conversation),
114
- key: rec.id,
115
- text: `${discussionBlock(discussion.text)}${rec.baseText}`,
116
- }, { api: apiBaseUrl, botToken, chatId: rec.chatId, filesDir: attachmentsDir(stateHome) }, {
117
- primary: { imageFileIds: rec.imageFileIds, fileIds: rec.fileIds },
118
- buffered: collectAttachments(discussion.consumed, {
119
- files: new Set(rec.fileIds),
120
- images: new Set(rec.imageFileIds),
121
- }),
122
- }), apiBaseUrl, botToken, targetOf(rec), formatError, rec.previewId, onAnswered),
123
- });
124
- runner.recover();
120
+ }), apiBaseUrl, botToken, targetOf(rec), formatError, rec.previewId, onAnswered),
121
+ });
122
+ runner.recover();
123
+ const accept = (update) => {
124
+ // Decide whether/where to answer, then run the turn.
125
+ const m = pickMessage(update);
126
+ if (!m)
127
+ return;
128
+ const placeKey = telegramPlaceKey(m.chat.id, m.message_thread_id);
129
+ const r = decide(update);
130
+ if (!r) {
131
+ // Not summoned: in a group, record the message so a later summon has the discussion (needs privacy off to be
132
+ // delivered here at all).
133
+ const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
134
+ const content = messageText(m);
135
+ if (isGroup && content) {
136
+ // OWN attachments only: each message is its own buffer entry, so a reply's referenced attachment is already
137
+ // (or will be) the other entry's.
138
+ const fileIds = ownFiles(m);
139
+ const imageIds = ownImages(m);
140
+ // A captioned attachment renders as its caption.
141
+ const summary = attachmentSummary(m);
142
+ const bodyLine = summary && content !== summary ? `${content} ${summary}` : content;
143
+ buffer.push(placeKey, {
144
+ sender: fromLabel(m.from) ?? "someone",
145
+ body: bodyLine,
146
+ messageId: m.message_id,
147
+ replyTo: m.reply_to_message?.message_id,
148
+ fileIds: fileIds.length ? fileIds : undefined,
149
+ imageIds: imageIds.length ? imageIds : undefined,
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ const conversation = r.conversation ?? placeKey;
155
+ const chatId = r.chatId ?? m.chat.id;
156
+ // Reply to the summoning message in groups (threads the answer under the asker); a 1:1 DM needs no reply-quote.
157
+ const threadId = r.threadId ?? m.message_thread_id;
158
+ const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
159
+ const replyTo = m.chat.type !== "private" && sameTarget ? m.message_id : undefined;
160
+ // Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the run it stops.
161
+ // `abort` waits until what it stopped has ended, so the update is acknowledged first.
162
+ if (telegramStop(update, { botUsername: mentionName, botId })) {
163
+ sideTasks.track(dispatchStop(agent, recordedSession(conversation), "[telegram]")
164
+ .then((feedback) => sendMessage(apiBaseUrl, botToken, { chatId, threadId, replyTo }, feedback, { html: false }))
165
+ .catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`)));
166
+ return;
167
+ }
168
+ const baseText = r.text ?? telegramEnvelope(m);
169
+ const imageFileIds = extractImages(m);
170
+ const fileIds = extractFiles(m);
171
+ if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
172
+ // Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
173
+ runner.submit({
174
+ id: `${update.update_id}`,
175
+ conversation,
176
+ placeKey,
177
+ baseText,
178
+ chatId,
179
+ threadId,
180
+ replyTo,
181
+ imageFileIds,
182
+ fileIds,
183
+ }, true);
184
+ }
185
+ return;
186
+ };
187
+ return { accept, turnsIdle: () => runner.idle() };
188
+ }
189
+ /** Build a Telegram bot channel that receives by webhook: policy options in, a {@link ChannelModule} out. */
190
+ export function telegramChannel(options) {
191
+ const { secretToken, apiBaseUrl = TELEGRAM_API } = options;
192
+ return (ctx) => {
193
+ if (!secretToken) {
194
+ throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
195
+ }
196
+ const runtime = createTelegramRuntime({ ...options, apiBaseUrl }, ctx);
125
197
  const handler = async (req) => {
126
198
  if (req.method !== "POST")
127
199
  return text("POST only\n", 405);
@@ -139,71 +211,26 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
139
211
  catch {
140
212
  return text("invalid json\n", 400);
141
213
  }
142
- // Decide whether/where to answer, then run the turn.
143
- const m = pickMessage(update);
144
- if (!m)
145
- return new Response(null, { status: 200 });
146
- const placeKey = telegramPlaceKey(m.chat.id, m.message_thread_id);
147
- const r = decide(update);
148
- if (!r) {
149
- // Not summoned: in a group, record the message so a later summon has the discussion (needs privacy off to be
150
- // delivered here at all).
151
- const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
152
- const content = messageText(m);
153
- if (isGroup && content) {
154
- // OWN attachments only: each message is its own buffer entry, so a reply's referenced attachment is already
155
- // (or will be) the other entry's.
156
- const fileIds = ownFiles(m);
157
- const imageIds = ownImages(m);
158
- // A captioned attachment renders as its caption.
159
- const summary = attachmentSummary(m);
160
- const bodyLine = summary && content !== summary ? `${content} ${summary}` : content;
161
- buffer.push(placeKey, {
162
- sender: fromLabel(m.from) ?? "someone",
163
- body: bodyLine,
164
- messageId: m.message_id,
165
- replyTo: m.reply_to_message?.message_id,
166
- fileIds: fileIds.length ? fileIds : undefined,
167
- imageIds: imageIds.length ? imageIds : undefined,
168
- });
169
- }
170
- return new Response(null, { status: 200 });
171
- }
172
- const conversation = r.conversation ?? placeKey;
173
- const chatId = r.chatId ?? m.chat.id;
174
- // Reply to the summoning message in groups (threads the answer under the asker); a 1:1 DM needs no reply-quote.
175
- const threadId = r.threadId ?? m.message_thread_id;
176
- const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
177
- const replyTo = m.chat.type !== "private" && sameTarget ? m.message_id : undefined;
178
- // Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the run it stops.
179
- // `abort` waits until what it stopped has ended, so the update is acknowledged first.
180
- if (telegramStop(update, { botUsername: mentionName, botId })) {
181
- sideTasks.track(dispatchStop(agent, recordedSession(conversation), "[telegram]")
182
- .then((feedback) => sendMessage(apiBaseUrl, botToken, { chatId, threadId, replyTo }, feedback, { html: false }))
183
- .catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`)));
184
- return new Response(null, { status: 200 });
185
- }
186
- const baseText = r.text ?? telegramEnvelope(m);
187
- const imageFileIds = extractImages(m);
188
- const fileIds = extractFiles(m);
189
- if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
190
- // Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
191
- runner.submit({
192
- id: `${update.update_id}`,
193
- conversation,
194
- placeKey,
195
- baseText,
196
- chatId,
197
- threadId,
198
- replyTo,
199
- imageFileIds,
200
- fileIds,
201
- }, true);
202
- }
214
+ runtime.accept(update);
203
215
  return new Response(null, { status: 200 });
204
216
  };
205
217
  // Test/observability seam: await the fire-and-forget turns this handler enqueues.
206
- handler.turnsIdle = () => runner.idle();
218
+ handler.turnsIdle = runtime.turnsIdle;
207
219
  return { "POST /telegram": handler };
208
220
  };
209
221
  }
222
+ /**
223
+ * Build a Telegram bot channel that receives by long polling: the process asks Telegram for updates, so no public URL
224
+ * and no webhook secret are involved. It takes the webhook away from the bot first (`getUpdates` does not work while
225
+ * one is set), so a bot is polled by one process or reached by one webhook, never both.
226
+ */
227
+ export function telegramPollingChannel(options) {
228
+ const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_API;
229
+ return {
230
+ name: "telegram long polling",
231
+ connect(ctx, signal) {
232
+ const runtime = createTelegramRuntime({ ...options, apiBaseUrl }, ctx);
233
+ return pollTelegramUpdates({ apiBaseUrl, botToken: options.botToken, accept: runtime.accept }, signal);
234
+ },
235
+ };
236
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Readiness probe for a server THIS process can reach over HTTP — the local port `deploy docker --run` just
3
- * published, the live probes' own origins, and the public URL a host deploy must clear before it registers a webhook
3
+ * published, and the public URL a host deploy must clear before it registers a webhook
4
4
  * (`publicHealthGate` in deploy/channel-ingress.ts).
5
5
  */
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Whether `address` is `<publicUrl><path>`: the scheme, the host and the channel's own path. A mount prefix is
3
+ * `publicUrl`'s, since an embedding app's router may strip it before the channel sees the request.
4
+ */
5
+ export declare function addressedTo(address: string, publicUrl: string, path: string): boolean;
6
+ /** Record that an authenticated request from the platform reached the channel whose state is `stateDir`. */
7
+ export declare function recordReached(stateDir: string, request: Request): void;
8
+ /** The address an authenticated request last reached the channel at, as recorded, if any. */
9
+ export declare function readReached(stateDir: string): string | undefined;
10
+ /** Hear each address recorded for `stateDir` from now on; the answer stops it. */
11
+ export declare function onReached(stateDir: string, listener: (address: string) => void): () => void;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What proves a platform reaches this instance at a URL only a person sets (Slack's Request URL; Lark's where its
3
+ * configuration API is missing): an authenticated request from it, and the address it was sent to (SPEC.md §8,
4
+ * channel-setup.md §5). A platform's signature or token proves who sent a request, not where, so the address is read
5
+ * from the request's host and forwarding headers. The first-party channel records it, durable in its own state
6
+ * directory, and a serve in this process hears it at once.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { writeFileAtomic } from "../atomic-write.js";
11
+ import { log } from "../log.js";
12
+ const EVIDENCE_FILE = "reached.json";
13
+ /** Where the request was sent: its scheme, host and path, as the proxy in front of the server says it received them. */
14
+ function requestAddress(request) {
15
+ const url = new URL(request.url);
16
+ const first = (value) => value?.split(",")[0]?.trim() || undefined;
17
+ const scheme = first(request.headers.get("x-forwarded-proto")) ?? url.protocol.replace(/:$/, "");
18
+ const host = first(request.headers.get("x-forwarded-host")) ?? request.headers.get("host") ?? url.host;
19
+ return `${scheme}://${host}${url.pathname}`;
20
+ }
21
+ /**
22
+ * Whether `address` is `<publicUrl><path>`: the scheme, the host and the channel's own path. A mount prefix is
23
+ * `publicUrl`'s, since an embedding app's router may strip it before the channel sees the request.
24
+ */
25
+ export function addressedTo(address, publicUrl, path) {
26
+ const reached = new URL(address);
27
+ const expected = new URL(publicUrl);
28
+ return reached.protocol === expected.protocol && reached.host === expected.host && reached.pathname.endsWith(path);
29
+ }
30
+ const listeners = new Map();
31
+ const recorded = new Map();
32
+ /** Record that an authenticated request from the platform reached the channel whose state is `stateDir`. */
33
+ export function recordReached(stateDir, request) {
34
+ const address = requestAddress(request);
35
+ if (recorded.get(stateDir) === address)
36
+ return;
37
+ recorded.set(stateDir, address);
38
+ try {
39
+ writeFileAtomic(join(stateDir, EVIDENCE_FILE), `${JSON.stringify({ address, at: new Date().toISOString() })}\n`);
40
+ }
41
+ catch (error) {
42
+ // The request is answered either way; a record not kept costs a `manual` after the next restart, said here.
43
+ log.warn(`[fastagent] could not record that ${address} was reached: ${String(error)}`);
44
+ }
45
+ for (const listener of listeners.get(stateDir) ?? [])
46
+ listener(address);
47
+ }
48
+ /** The address an authenticated request last reached the channel at, as recorded, if any. */
49
+ export function readReached(stateDir) {
50
+ try {
51
+ const { address } = JSON.parse(readFileSync(join(stateDir, EVIDENCE_FILE), "utf8"));
52
+ return typeof address === "string" ? address : undefined;
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ return undefined;
57
+ log.warn(`[fastagent] could not read ${join(stateDir, EVIDENCE_FILE)}: ${String(error)}`);
58
+ return undefined;
59
+ }
60
+ }
61
+ /** Hear each address recorded for `stateDir` from now on; the answer stops it. */
62
+ export function onReached(stateDir, listener) {
63
+ const set = listeners.get(stateDir) ?? new Set();
64
+ set.add(listener);
65
+ listeners.set(stateDir, set);
66
+ return () => {
67
+ set.delete(listener);
68
+ if (set.size === 0)
69
+ listeners.delete(stateDir);
70
+ };
71
+ }