@spinabot/brigade 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/convex/channels.d.ts +5 -5
  2. package/convex/schema.d.ts +2 -2
  3. package/dist/agents/agent-loop.d.ts.map +1 -1
  4. package/dist/agents/agent-loop.js +27 -4
  5. package/dist/agents/agent-loop.js.map +1 -1
  6. package/dist/agents/channels/approval-callback-codec.d.ts +107 -0
  7. package/dist/agents/channels/approval-callback-codec.d.ts.map +1 -0
  8. package/dist/agents/channels/approval-callback-codec.js +173 -0
  9. package/dist/agents/channels/approval-callback-codec.js.map +1 -0
  10. package/dist/agents/channels/approval-router.d.ts +77 -20
  11. package/dist/agents/channels/approval-router.d.ts.map +1 -1
  12. package/dist/agents/channels/approval-router.js +163 -37
  13. package/dist/agents/channels/approval-router.js.map +1 -1
  14. package/dist/agents/channels/backoff.d.ts +55 -0
  15. package/dist/agents/channels/backoff.d.ts.map +1 -0
  16. package/dist/agents/channels/backoff.js +47 -0
  17. package/dist/agents/channels/backoff.js.map +1 -0
  18. package/dist/agents/channels/channel-secrets.d.ts +45 -0
  19. package/dist/agents/channels/channel-secrets.d.ts.map +1 -0
  20. package/dist/agents/channels/channel-secrets.js +69 -0
  21. package/dist/agents/channels/channel-secrets.js.map +1 -0
  22. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  23. package/dist/agents/channels/inbound-pipeline.js +67 -3
  24. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  25. package/dist/agents/channels/last-sent-message.d.ts +46 -0
  26. package/dist/agents/channels/last-sent-message.d.ts.map +1 -0
  27. package/dist/agents/channels/last-sent-message.js +55 -0
  28. package/dist/agents/channels/last-sent-message.js.map +1 -0
  29. package/dist/agents/channels/manager.d.ts +52 -0
  30. package/dist/agents/channels/manager.d.ts.map +1 -1
  31. package/dist/agents/channels/manager.js +141 -31
  32. package/dist/agents/channels/manager.js.map +1 -1
  33. package/dist/agents/channels/plugin-channel-manager-facade.d.ts +13 -2
  34. package/dist/agents/channels/plugin-channel-manager-facade.d.ts.map +1 -1
  35. package/dist/agents/channels/plugin-channel-manager-facade.js +21 -0
  36. package/dist/agents/channels/plugin-channel-manager-facade.js.map +1 -1
  37. package/dist/agents/channels/sdk.d.ts +426 -0
  38. package/dist/agents/channels/sdk.d.ts.map +1 -0
  39. package/dist/agents/channels/sdk.js +274 -0
  40. package/dist/agents/channels/sdk.js.map +1 -0
  41. package/dist/agents/channels/telegram/account-config.d.ts +92 -0
  42. package/dist/agents/channels/telegram/account-config.d.ts.map +1 -0
  43. package/dist/agents/channels/telegram/account-config.js +192 -0
  44. package/dist/agents/channels/telegram/account-config.js.map +1 -0
  45. package/dist/agents/channels/telegram/adapter.d.ts +79 -0
  46. package/dist/agents/channels/telegram/adapter.d.ts.map +1 -0
  47. package/dist/agents/channels/telegram/adapter.js +475 -0
  48. package/dist/agents/channels/telegram/adapter.js.map +1 -0
  49. package/dist/agents/channels/telegram/allowed-updates.d.ts +44 -0
  50. package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -0
  51. package/dist/agents/channels/telegram/allowed-updates.js +52 -0
  52. package/dist/agents/channels/telegram/allowed-updates.js.map +1 -0
  53. package/dist/agents/channels/telegram/approval-authorize.d.ts +41 -0
  54. package/dist/agents/channels/telegram/approval-authorize.d.ts.map +1 -0
  55. package/dist/agents/channels/telegram/approval-authorize.js +69 -0
  56. package/dist/agents/channels/telegram/approval-authorize.js.map +1 -0
  57. package/dist/agents/channels/telegram/approval-native.d.ts +68 -0
  58. package/dist/agents/channels/telegram/approval-native.d.ts.map +1 -0
  59. package/dist/agents/channels/telegram/approval-native.js +94 -0
  60. package/dist/agents/channels/telegram/approval-native.js.map +1 -0
  61. package/dist/agents/channels/telegram/command-menu.d.ts +35 -0
  62. package/dist/agents/channels/telegram/command-menu.d.ts.map +1 -0
  63. package/dist/agents/channels/telegram/command-menu.js +59 -0
  64. package/dist/agents/channels/telegram/command-menu.js.map +1 -0
  65. package/dist/agents/channels/telegram/connection.d.ts +359 -0
  66. package/dist/agents/channels/telegram/connection.d.ts.map +1 -0
  67. package/dist/agents/channels/telegram/connection.js +865 -0
  68. package/dist/agents/channels/telegram/connection.js.map +1 -0
  69. package/dist/agents/channels/telegram/format.d.ts +48 -0
  70. package/dist/agents/channels/telegram/format.d.ts.map +1 -0
  71. package/dist/agents/channels/telegram/format.js +256 -0
  72. package/dist/agents/channels/telegram/format.js.map +1 -0
  73. package/dist/agents/channels/telegram/inbound-extras.d.ts +73 -0
  74. package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -0
  75. package/dist/agents/channels/telegram/inbound-extras.js +231 -0
  76. package/dist/agents/channels/telegram/inbound-extras.js.map +1 -0
  77. package/dist/agents/channels/telegram/index.d.ts +14 -0
  78. package/dist/agents/channels/telegram/index.d.ts.map +1 -0
  79. package/dist/agents/channels/telegram/index.js +14 -0
  80. package/dist/agents/channels/telegram/index.js.map +1 -0
  81. package/dist/agents/channels/telegram/media.d.ts +68 -0
  82. package/dist/agents/channels/telegram/media.d.ts.map +1 -0
  83. package/dist/agents/channels/telegram/media.js +143 -0
  84. package/dist/agents/channels/telegram/media.js.map +1 -0
  85. package/dist/agents/channels/telegram/module.d.ts +15 -0
  86. package/dist/agents/channels/telegram/module.d.ts.map +1 -0
  87. package/dist/agents/channels/telegram/module.js +36 -0
  88. package/dist/agents/channels/telegram/module.js.map +1 -0
  89. package/dist/agents/channels/telegram/plugin.d.ts +76 -0
  90. package/dist/agents/channels/telegram/plugin.d.ts.map +1 -0
  91. package/dist/agents/channels/telegram/plugin.js +314 -0
  92. package/dist/agents/channels/telegram/plugin.js.map +1 -0
  93. package/dist/agents/channels/telegram/probe.d.ts +54 -0
  94. package/dist/agents/channels/telegram/probe.d.ts.map +1 -0
  95. package/dist/agents/channels/telegram/probe.js +95 -0
  96. package/dist/agents/channels/telegram/probe.js.map +1 -0
  97. package/dist/agents/channels/telegram/webhook.d.ts +55 -0
  98. package/dist/agents/channels/telegram/webhook.d.ts.map +1 -0
  99. package/dist/agents/channels/telegram/webhook.js +141 -0
  100. package/dist/agents/channels/telegram/webhook.js.map +1 -0
  101. package/dist/agents/extensions/modules/index.d.ts.map +1 -1
  102. package/dist/agents/extensions/modules/index.js +4 -0
  103. package/dist/agents/extensions/modules/index.js.map +1 -1
  104. package/dist/agents/extensions/types.d.ts +72 -2
  105. package/dist/agents/extensions/types.d.ts.map +1 -1
  106. package/dist/agents/extensions/types.js.map +1 -1
  107. package/dist/agents/tools/connect-channel-tool.d.ts +86 -0
  108. package/dist/agents/tools/connect-channel-tool.d.ts.map +1 -0
  109. package/dist/agents/tools/connect-channel-tool.js +398 -0
  110. package/dist/agents/tools/connect-channel-tool.js.map +1 -0
  111. package/dist/agents/tools/message-action-tool.d.ts +67 -0
  112. package/dist/agents/tools/message-action-tool.d.ts.map +1 -0
  113. package/dist/agents/tools/message-action-tool.js +216 -0
  114. package/dist/agents/tools/message-action-tool.js.map +1 -0
  115. package/dist/agents/tools/registry.d.ts.map +1 -1
  116. package/dist/agents/tools/registry.js +19 -0
  117. package/dist/agents/tools/registry.js.map +1 -1
  118. package/dist/buildstamp.json +1 -1
  119. package/dist/cli/commands/channels.d.ts.map +1 -1
  120. package/dist/cli/commands/channels.js +27 -2
  121. package/dist/cli/commands/channels.js.map +1 -1
  122. package/dist/core/server.d.ts.map +1 -1
  123. package/dist/core/server.js +77 -27
  124. package/dist/core/server.js.map +1 -1
  125. package/dist/cron/service/state.d.ts +10 -0
  126. package/dist/cron/service/state.d.ts.map +1 -1
  127. package/dist/cron/service/state.js.map +1 -1
  128. package/dist/cron/service/timer.d.ts.map +1 -1
  129. package/dist/cron/service/timer.js +43 -14
  130. package/dist/cron/service/timer.js.map +1 -1
  131. package/dist/cron/session-reaper.d.ts +27 -0
  132. package/dist/cron/session-reaper.d.ts.map +1 -1
  133. package/dist/cron/session-reaper.js +81 -0
  134. package/dist/cron/session-reaper.js.map +1 -1
  135. package/dist/system-prompt/assembler.d.ts +14 -0
  136. package/dist/system-prompt/assembler.d.ts.map +1 -1
  137. package/dist/system-prompt/assembler.js +36 -14
  138. package/dist/system-prompt/assembler.js.map +1 -1
  139. package/package.json +22 -6
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Telegram Bot API connection (grammY long-polling).
3
+ *
4
+ * The Brigade analogue of `whatsapp/connection.ts`, distilled from the
5
+ * reference Telegram polling session. grammY + its runner + the throttler transformer are
6
+ * HEAVY and only needed when a Telegram channel actually starts, so they are
7
+ * lazy-imported here (`await import("grammy")` inside `connectTelegram`) — a
8
+ * non-Telegram boot never pays for them. Types are `type`-only so the static
9
+ * import never pulls the runtime in.
10
+ *
11
+ * Lifecycle:
12
+ * - `deleteWebhook({ drop_pending_updates: false })` is called BEFORE polling.
13
+ * If a webhook was ever set on this bot, `getUpdates` returns 409 forever
14
+ * and the bot silently "receives nothing" — clearing it first is the #1 fix.
15
+ * - `getMe` caches the bot's numeric id + @username (the group ACL needs the
16
+ * username to detect @-mentions; without it group messages never reach the
17
+ * agent).
18
+ * - `bot.on("message")` normalizes each update into a `TgInboundMessage` and
19
+ * hands it to `onMessage` with a DEFERRED `resolveMedia` thunk — bytes are
20
+ * downloaded only after the central access gate admits the sender (mirrors
21
+ * WhatsApp).
22
+ * - The grammY runner drives `getUpdates`; `apiThrottler()` rate-limits the
23
+ * outbound API.
24
+ * - Reconnect backoff COPIES WhatsApp's constants (2s → 30s, ×1.8, ±25%).
25
+ * - 401 Unauthorized → sticky `tokenInvalid` (terminal; the only fix is a new
26
+ * token — stop polling).
27
+ * - 409 Conflict (getUpdates) → another poller/webhook is live: clear the
28
+ * webhook and restart ONCE, then fall back to normal backoff.
29
+ * - Updates are de-duplicated by `update_id` (a redelivered update after a
30
+ * restart must not double-run the agent).
31
+ *
32
+ * Scope cut (v1): `callback_query` (inline-button taps) is intentionally NOT
33
+ * subscribed — Brigade's approvals are central TEXT replies handled in
34
+ * `inbound-pipeline.ts`, so `allowed_updates` only needs message updates.
35
+ */
36
+ import { createDedupeCache, nextBackoffDelay, } from "../sdk.js";
37
+ import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
38
+ import { buildTelegramSenderName, extractTelegramMentions, extractTelegramReplyContext, extractTelegramText, hasInboundMedia, resolveInboundMediaFileId, resolveInboundMediaKind, telegramChatType, telegramThreadId, } from "./inbound-extras.js";
39
+ import { downloadTelegramMedia } from "./media.js";
40
+ // All contract types — `OutboundMedia`, `InboundMediaAttachment`,
41
+ // `InboundReplyContext` — now come from the channel SDK barrel (imported above),
42
+ // so this channel is built entirely on `../sdk.js`.
43
+ /* ───────────────────────── reconnect backoff ───────────────────────── */
44
+ // Shares the neutral `nextBackoffDelay` curve with every other channel (see
45
+ // `channels/backoff.ts`), tuned to the same schedule WhatsApp uses (2s → 30s,
46
+ // ×1.8, ±25%). The constants live here so Telegram owns its own knobs; the
47
+ // arithmetic is the shared helper's.
48
+ const RECONNECT_INITIAL_MS = 2_000;
49
+ const RECONNECT_MAX_MS = 30_000;
50
+ const RECONNECT_FACTOR = 1.8;
51
+ const RECONNECT_JITTER = 0.25;
52
+ const RECONNECT_MAX_ATTEMPTS = 12;
53
+ /**
54
+ * Jittered exponential backoff for reconnect attempt `attempt` (0-based).
55
+ * Thin wrapper over the neutral `nextBackoffDelay` helper — kept as a named
56
+ * export so `index.ts` and the connection tests have a stable entry point.
57
+ */
58
+ export function telegramBackoffDelay(attempt) {
59
+ return nextBackoffDelay({
60
+ attempt,
61
+ initialMs: RECONNECT_INITIAL_MS,
62
+ maxMs: RECONNECT_MAX_MS,
63
+ factor: RECONNECT_FACTOR,
64
+ jitter: RECONNECT_JITTER,
65
+ });
66
+ }
67
+ /* ───────────────────────── error classification ───────────────────────── */
68
+ /** Pull a Telegram `error_code` off any thrown shape (grammY GrammyError or raw). */
69
+ function errorCode(err) {
70
+ if (!err || typeof err !== "object")
71
+ return undefined;
72
+ const e = err;
73
+ return e.error_code ?? e.errorCode;
74
+ }
75
+ /** Description / message text off any thrown shape. */
76
+ function errorText(err) {
77
+ if (!err)
78
+ return "";
79
+ if (typeof err === "string")
80
+ return err;
81
+ const e = err;
82
+ return e.description ?? e.message ?? String(err);
83
+ }
84
+ /** 401 Unauthorized → the bot token is wrong / revoked (terminal). */
85
+ export function isTelegramUnauthorized(err) {
86
+ if (errorCode(err) === 401)
87
+ return true;
88
+ return /unauthorized/i.test(errorText(err));
89
+ }
90
+ /** 409 Conflict on getUpdates → another poller or a webhook is active. */
91
+ export function isTelegramGetUpdatesConflict(err) {
92
+ if (errorCode(err) !== 409)
93
+ return false;
94
+ return /conflict|getupdates|webhook|terminated by other/i.test(errorText(err));
95
+ }
96
+ /** Strip a bot token out of any string before it reaches a log. */
97
+ export function redactTelegramToken(text, token) {
98
+ if (!text)
99
+ return text;
100
+ let out = text;
101
+ if (token)
102
+ out = out.split(token).join("<redacted>");
103
+ // Catch `bot<digits>:<base64ish>` URL fragments even if the exact token differs.
104
+ out = out.replace(/bot\d{6,}:[A-Za-z0-9_-]{20,}/g, "bot<redacted>");
105
+ return out;
106
+ }
107
+ /* ───────────────────────── the connection ───────────────────────── */
108
+ export async function connectTelegram(args) {
109
+ const accountId = args.accountId ?? "default";
110
+ const mode = args.mode === "webhook" ? "webhook" : "polling";
111
+ const sleep = args.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
112
+ const safeLog = (msg, meta) => {
113
+ // Defensively redact the token from any message + string meta values.
114
+ const redactedMsg = redactTelegramToken(msg, args.token);
115
+ if (!meta)
116
+ return args.log(redactedMsg);
117
+ const redactedMeta = {};
118
+ for (const [k, v] of Object.entries(meta)) {
119
+ redactedMeta[k] = typeof v === "string" ? redactTelegramToken(v, args.token) : v;
120
+ }
121
+ args.log(redactedMsg, redactedMeta);
122
+ };
123
+ // ── lazy-load grammY + runner + throttler (production path only) ──
124
+ let buildBot;
125
+ let buildRunner;
126
+ if (args.botFactory && args.runnerFactory) {
127
+ buildBot = args.botFactory;
128
+ buildRunner = args.runnerFactory;
129
+ }
130
+ else {
131
+ const grammy = await import("grammy");
132
+ const { run } = await import("@grammyjs/runner");
133
+ const { apiThrottler } = await import("@grammyjs/transformer-throttler");
134
+ buildBot = (token) => {
135
+ const bot = new grammy.Bot(token);
136
+ // Rate-limit the outbound API so a chatty agent never trips Telegram's
137
+ // flood limits — installed on the api config as a transformer.
138
+ bot.api.config?.use(apiThrottler());
139
+ return bot;
140
+ };
141
+ buildRunner = (bot) => run(bot, {
142
+ // Subscribe `message` (text/media) + `callback_query` (inline-button
143
+ // approval presses) — the minimal set Brigade's central pipeline
144
+ // consumes. Widen via `args.allowedUpdates` (e.g. inbound reactions).
145
+ // Cast: the resolver returns a strict subset of grammY's update union,
146
+ // but `args.allowedUpdates` is a plain `string[]`.
147
+ runner: { fetch: { allowed_updates: allowedUpdates } },
148
+ });
149
+ }
150
+ // The `allowed_updates` list to request — defaults to message + callback_query.
151
+ const allowedUpdates = args.allowedUpdates && args.allowedUpdates.length > 0
152
+ ? args.allowedUpdates
153
+ : resolveTelegramAllowedUpdates();
154
+ // ── connection state ──
155
+ let selfId = null;
156
+ let selfUsername = null;
157
+ let selfIdentity = null;
158
+ let connectedAtMs = null;
159
+ let connected = false;
160
+ let tokenInvalid = false;
161
+ let closed = false;
162
+ let webhookCleared = false;
163
+ let conflictRestartUsed = false;
164
+ let reconnectAttempts = 0;
165
+ let bot = null;
166
+ let runner = null;
167
+ let loopPromise = null;
168
+ // Dedupe inbound updates by `update_id` — a redelivered update after a
169
+ // restart must not double-run the agent. Per-connection lifetime.
170
+ const updateDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
171
+ /** Normalize one grammY message into the deferred-media inbound shape. */
172
+ const normalize = (message) => {
173
+ const chatId = String(message.chat.id);
174
+ const text = extractTelegramText(message);
175
+ const chatType = telegramChatType(message);
176
+ const threadId = telegramThreadId(message);
177
+ const mentions = extractTelegramMentions(message, selfUsername ?? undefined, selfId ?? undefined);
178
+ const replyTo = extractTelegramReplyContext(message);
179
+ const fromName = buildTelegramSenderName(message);
180
+ const fromId = typeof message.from?.id === "number" ? String(message.from.id) : chatId;
181
+ const tsSec = typeof message.date === "number" ? message.date : 0;
182
+ // DEFERRED media — captured by reference, not downloaded. The thunk is only
183
+ // invoked by the pipeline after the access gate admits the sender.
184
+ const carriesMedia = hasInboundMedia(message);
185
+ const resolveMedia = carriesMedia
186
+ ? async () => {
187
+ const fileId = resolveInboundMediaFileId(message);
188
+ const kind = resolveInboundMediaKind(message);
189
+ if (!fileId || !kind || !bot)
190
+ return [];
191
+ const caption = typeof message.caption === "string" ? message.caption : undefined;
192
+ const fileName = message.document?.file_name ?? message.audio?.file_name ?? message.video?.file_name;
193
+ const att = await downloadTelegramMedia({
194
+ bot: bot.api,
195
+ fileId,
196
+ kind,
197
+ token: args.token,
198
+ caption,
199
+ fileName,
200
+ log: safeLog,
201
+ });
202
+ return att ? [att] : [];
203
+ }
204
+ : undefined;
205
+ return {
206
+ conversationId: chatId,
207
+ messageId: typeof message.message_id === "number" ? String(message.message_id) : undefined,
208
+ messageTimestampMs: tsSec > 0 ? tsSec * 1000 : undefined,
209
+ from: fromId,
210
+ fromName,
211
+ text,
212
+ chatType,
213
+ threadId,
214
+ mentions: mentions.length > 0 ? mentions : undefined,
215
+ replyTo,
216
+ resolveMedia,
217
+ raw: message,
218
+ };
219
+ };
220
+ const onUpdate = (ctx) => {
221
+ try {
222
+ const updateId = ctx.update?.update_id;
223
+ if (typeof updateId === "number" && !updateDedupe.claim(String(updateId)))
224
+ return; // already seen
225
+ const message = ctx.message ?? ctx.update?.message;
226
+ if (!message)
227
+ return;
228
+ // Ignore the bot's own outbound echoes (a bot never sees its own sends
229
+ // via getUpdates, but a linked-account self-message would carry our id).
230
+ if (typeof message.from?.id === "number" && selfId && String(message.from.id) === selfId)
231
+ return;
232
+ args.onMessage(normalize(message));
233
+ }
234
+ catch (err) {
235
+ safeLog("telegram inbound handler error", { error: err instanceof Error ? err.message : String(err) });
236
+ }
237
+ };
238
+ /**
239
+ * Normalize a `callback_query` (inline-button press) into the inbound shape
240
+ * the central pipeline routes to the approval-callback path. The button's
241
+ * `data` rides on `callbackQuery`; `conversationId` / `from` / `threadId`
242
+ * come from the message the button was attached to so the pending-approval
243
+ * lookup keys on the SAME peer the prompt was sent to.
244
+ */
245
+ const normalizeCallback = (cb) => {
246
+ const data = typeof cb.data === "string" ? cb.data : "";
247
+ if (!data)
248
+ return null; // a button with no payload is not an approval press
249
+ const msg = cb.message;
250
+ const chatId = msg?.chat?.id !== undefined ? String(msg.chat.id) : cb.from?.id !== undefined ? String(cb.from.id) : "";
251
+ if (!chatId)
252
+ return null;
253
+ const fromId = typeof cb.from?.id === "number" ? String(cb.from.id) : chatId;
254
+ const fromName = cb.from
255
+ ? [cb.from.first_name, cb.from.last_name].filter(Boolean).join(" ").trim() ||
256
+ (cb.from.username ? `@${cb.from.username}` : undefined)
257
+ : undefined;
258
+ const threadId = msg ? telegramThreadId(msg) : undefined;
259
+ const chatType = msg ? telegramChatType(msg) : "direct";
260
+ return {
261
+ conversationId: chatId,
262
+ from: fromId,
263
+ ...(fromName ? { fromName } : {}),
264
+ text: "",
265
+ chatType,
266
+ ...(threadId ? { threadId } : {}),
267
+ callbackQuery: { data, callbackId: cb.id },
268
+ raw: (msg ?? cb),
269
+ };
270
+ };
271
+ const onCallbackQuery = async (ctx) => {
272
+ try {
273
+ const updateId = ctx.update?.update_id;
274
+ if (typeof updateId === "number" && !updateDedupe.claim(String(updateId)))
275
+ return; // already seen
276
+ const cb = ctx.callbackQuery ?? ctx.update.callback_query;
277
+ if (!cb)
278
+ return;
279
+ // ACK first — clears the client-side loading spinner immediately (no text,
280
+ // matching the reference). Best-effort: a failed ack must not block routing.
281
+ try {
282
+ await ctx.answerCallbackQuery();
283
+ }
284
+ catch {
285
+ /* ack is cosmetic */
286
+ }
287
+ const normalized = normalizeCallback(cb);
288
+ if (!normalized)
289
+ return;
290
+ args.onCallbackQuery?.(normalized);
291
+ }
292
+ catch (err) {
293
+ safeLog("telegram callback handler error", { error: err instanceof Error ? err.message : String(err) });
294
+ }
295
+ };
296
+ /** Build a bot, wire handlers, getMe, clear webhook, sync commands, start runner. */
297
+ const startOnce = async () => {
298
+ const b = buildBot(args.token);
299
+ b.on("message", onUpdate);
300
+ // Subscribe inline-button presses (interactive approvals). grammY hands a
301
+ // per-update `answerCallbackQuery` helper on the ctx; the handler acks via
302
+ // it then routes the normalized callback inbound.
303
+ b.on("callback_query", (ctx) => void onCallbackQuery(ctx));
304
+ bot = b;
305
+ // getMe first — both proves the token (401 surfaces here) and caches the
306
+ // bot id + username the group ACL needs (+ the full identity for the probe).
307
+ const me = await b.api.getMe();
308
+ selfId = String(me.id);
309
+ selfUsername = me.username ?? null;
310
+ selfIdentity = me;
311
+ // Clear any webhook BEFORE polling — the #1 "receives nothing" cause. Do
312
+ // not drop pending updates (the operator may want queued messages). Once
313
+ // cleared we don't re-clear on every reconnect.
314
+ if (!webhookCleared) {
315
+ await b.api.deleteWebhook({ drop_pending_updates: false });
316
+ webhookCleared = true;
317
+ }
318
+ // Register the bot's `/` command menu (best-effort — a failed sync must not
319
+ // block polling). Re-applied on each (re)connect so an edited command set
320
+ // lands after a gateway restart.
321
+ if (args.commandMenu && args.commandMenu.length > 0 && b.api.setMyCommands) {
322
+ try {
323
+ await b.api.setMyCommands(args.commandMenu);
324
+ }
325
+ catch (err) {
326
+ safeLog("telegram setMyCommands failed (cosmetic)", {
327
+ error: err instanceof Error ? err.message : String(err),
328
+ });
329
+ }
330
+ }
331
+ const r = buildRunner(b);
332
+ runner = r;
333
+ return r;
334
+ };
335
+ /**
336
+ * Feed a raw update into the inbound path. In webhook mode the gateway HTTP
337
+ * route calls this with each POSTed `Update`; it dispatches to the same
338
+ * message / callback_query handlers polling uses (so dedupe + normalize +
339
+ * ack are identical). grammY's polling ctx supplies a per-update
340
+ * `answerCallbackQuery`; in webhook mode we synthesise one off `bot.api`.
341
+ */
342
+ const feedUpdate = (update) => {
343
+ const message = update.message;
344
+ if (message) {
345
+ onUpdate({ update, message });
346
+ return;
347
+ }
348
+ const callbackQuery = update.callback_query;
349
+ if (callbackQuery) {
350
+ void onCallbackQuery({
351
+ update,
352
+ callbackQuery,
353
+ answerCallbackQuery: async (opts) => {
354
+ const b = bot;
355
+ if (!b?.api.answerCallbackQuery)
356
+ return;
357
+ await b.api.answerCallbackQuery(callbackQuery.id, opts);
358
+ },
359
+ });
360
+ }
361
+ };
362
+ /**
363
+ * Webhook transport start: build the bot, getMe, sync commands, then register
364
+ * the webhook (when a url was supplied) so Telegram POSTs updates to the
365
+ * gateway route. Does NOT poll — inbound arrives via {@link feedUpdate}.
366
+ */
367
+ const startWebhook = async () => {
368
+ const b = buildBot(args.token);
369
+ // We don't subscribe via b.on(...) in webhook mode — feedUpdate dispatches
370
+ // directly — but wiring the handlers is harmless and keeps parity if a
371
+ // future grammy webhookCallback is adopted.
372
+ bot = b;
373
+ const me = await b.api.getMe();
374
+ selfId = String(me.id);
375
+ selfUsername = me.username ?? null;
376
+ selfIdentity = me;
377
+ if (args.commandMenu && args.commandMenu.length > 0 && b.api.setMyCommands) {
378
+ try {
379
+ await b.api.setMyCommands(args.commandMenu);
380
+ }
381
+ catch (err) {
382
+ safeLog("telegram setMyCommands failed (cosmetic)", {
383
+ error: err instanceof Error ? err.message : String(err),
384
+ });
385
+ }
386
+ }
387
+ const url = args.webhook?.url?.trim();
388
+ if (url && b.api.setWebhook) {
389
+ const opts = { allowed_updates: allowedUpdates };
390
+ if (args.webhook?.secretToken)
391
+ opts.secret_token = args.webhook.secretToken;
392
+ await b.api.setWebhook(url, opts);
393
+ safeLog("telegram webhook registered", { account: accountId });
394
+ }
395
+ else {
396
+ safeLog("telegram webhook mode — no url configured; inbound only via gateway route", {
397
+ account: accountId,
398
+ });
399
+ }
400
+ connected = true;
401
+ connectedAtMs = Date.now();
402
+ reconnectAttempts = 0;
403
+ args.onConnected?.();
404
+ };
405
+ /** The supervise loop — start, run until the runner stops, reconnect with backoff. */
406
+ const superviseLoop = async () => {
407
+ while (!closed && !tokenInvalid) {
408
+ let r;
409
+ try {
410
+ r = await startOnce();
411
+ }
412
+ catch (err) {
413
+ if (isTelegramUnauthorized(err)) {
414
+ tokenInvalid = true;
415
+ connected = false;
416
+ safeLog("telegram token rejected (401) — re-token required; polling stopped");
417
+ args.onTokenInvalid?.();
418
+ return;
419
+ }
420
+ if (closed)
421
+ return;
422
+ // Setup failed (transient network on getMe/deleteWebhook) — back off + retry.
423
+ const delay = telegramBackoffDelay(reconnectAttempts);
424
+ reconnectAttempts += 1;
425
+ if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
426
+ safeLog("telegram setup attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
427
+ return;
428
+ }
429
+ safeLog("telegram setup failed — retrying", {
430
+ attempt: reconnectAttempts,
431
+ delayMs: delay,
432
+ error: err instanceof Error ? err.message : String(err),
433
+ });
434
+ await sleep(delay);
435
+ continue;
436
+ }
437
+ // close() may have fired during the async startOnce() — bail before we
438
+ // commit to driving a runner we'd otherwise have to wait out.
439
+ if (closed) {
440
+ await teardownRunner();
441
+ return;
442
+ }
443
+ // Connected. Reset backoff, announce.
444
+ connected = true;
445
+ connectedAtMs = Date.now();
446
+ reconnectAttempts = 0;
447
+ conflictRestartUsed = false;
448
+ safeLog("telegram connected", { account: accountId, self: selfUsername ? `@${selfUsername}` : selfId });
449
+ args.onConnected?.();
450
+ // Drive the runner until it stops (graceful or error).
451
+ try {
452
+ await r.task();
453
+ // Runner stopped without throwing — graceful stop (close) or maxRetry.
454
+ if (closed)
455
+ return;
456
+ connected = false;
457
+ const delay = telegramBackoffDelay(reconnectAttempts);
458
+ reconnectAttempts += 1;
459
+ if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
460
+ safeLog("telegram polling attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
461
+ return;
462
+ }
463
+ safeLog("telegram polling stopped — reconnecting", { attempt: reconnectAttempts, delayMs: delay });
464
+ await sleep(delay);
465
+ }
466
+ catch (err) {
467
+ connected = false;
468
+ if (closed)
469
+ return;
470
+ if (isTelegramUnauthorized(err)) {
471
+ tokenInvalid = true;
472
+ safeLog("telegram token rejected (401) — re-token required; polling stopped");
473
+ args.onTokenInvalid?.();
474
+ return;
475
+ }
476
+ if (isTelegramGetUpdatesConflict(err)) {
477
+ // Another poller / a leftover webhook is live. Clear the webhook
478
+ // and restart ONCE immediately; if it recurs, fall through to backoff.
479
+ if (!conflictRestartUsed) {
480
+ conflictRestartUsed = true;
481
+ webhookCleared = false; // force a re-clear on the next startOnce
482
+ safeLog("telegram getUpdates conflict (409) — clearing webhook + restarting once");
483
+ await teardownRunner();
484
+ continue;
485
+ }
486
+ safeLog("telegram getUpdates conflict (409) persists — backing off");
487
+ }
488
+ const delay = telegramBackoffDelay(reconnectAttempts);
489
+ reconnectAttempts += 1;
490
+ if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
491
+ safeLog("telegram polling attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
492
+ return;
493
+ }
494
+ safeLog("telegram polling error — reconnecting", {
495
+ attempt: reconnectAttempts,
496
+ delayMs: delay,
497
+ error: err instanceof Error ? err.message : String(err),
498
+ });
499
+ await sleep(delay);
500
+ }
501
+ finally {
502
+ await teardownRunner();
503
+ }
504
+ }
505
+ };
506
+ const teardownRunner = async () => {
507
+ const r = runner;
508
+ runner = null;
509
+ if (r) {
510
+ try {
511
+ if (r.isRunning())
512
+ await r.stop();
513
+ }
514
+ catch {
515
+ /* already stopped */
516
+ }
517
+ }
518
+ const b = bot;
519
+ if (b) {
520
+ try {
521
+ await b.stop();
522
+ }
523
+ catch {
524
+ /* already stopped */
525
+ }
526
+ }
527
+ };
528
+ // Kick the supervise loop. It resolves the initial connect via onConnected;
529
+ // connectTelegram itself resolves as soon as the FIRST connect (or terminal
530
+ // failure) settles so the adapter's start() doesn't hang forever.
531
+ let resolveInitial;
532
+ const initial = new Promise((resolve) => {
533
+ resolveInitial = resolve;
534
+ });
535
+ const origOnConnected = args.onConnected;
536
+ const origOnTokenInvalid = args.onTokenInvalid;
537
+ args.onConnected = () => {
538
+ origOnConnected?.();
539
+ resolveInitial();
540
+ };
541
+ args.onTokenInvalid = () => {
542
+ origOnTokenInvalid?.();
543
+ resolveInitial();
544
+ };
545
+ if (mode === "webhook") {
546
+ // Webhook transport: one-shot setup (no poll loop). A 401 surfaces as a
547
+ // terminal token-invalid; any other setup error is logged and the channel
548
+ // stays "starting" until the operator fixes config + restarts.
549
+ loopPromise = startWebhook().catch((err) => {
550
+ if (isTelegramUnauthorized(err)) {
551
+ tokenInvalid = true;
552
+ connected = false;
553
+ safeLog("telegram token rejected (401) — re-token required; webhook not registered");
554
+ args.onTokenInvalid?.();
555
+ return;
556
+ }
557
+ safeLog("telegram webhook setup failed", { error: err instanceof Error ? err.message : String(err) });
558
+ });
559
+ }
560
+ else {
561
+ loopPromise = superviseLoop().catch((err) => {
562
+ safeLog("telegram supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
563
+ });
564
+ }
565
+ // Don't block start() indefinitely — resolve once connected OR after the loop
566
+ // settles (terminal failure), whichever comes first.
567
+ await Promise.race([initial, loopPromise.then(() => undefined)]);
568
+ /* ── outbound + control surface ── */
569
+ const requireLive = () => {
570
+ if (tokenInvalid)
571
+ throw new Error("Telegram token is invalid — set a new bot token and restart.");
572
+ if (!bot)
573
+ throw new Error("Telegram channel is not started");
574
+ return bot;
575
+ };
576
+ const sendText = async (chatId, text, opts) => {
577
+ const b = requireLive();
578
+ const params = {};
579
+ if (opts?.html)
580
+ params.parse_mode = "HTML";
581
+ if (opts?.threadId)
582
+ params.message_thread_id = Number(opts.threadId);
583
+ try {
584
+ const res = await b.api.sendMessage(chatId, text, params);
585
+ return { messageId: res.message_id };
586
+ }
587
+ catch (err) {
588
+ // Thread vanished — retry without the thread param (topic deleted/closed).
589
+ if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
590
+ const { message_thread_id: _omit, ...rest } = params;
591
+ const res = await b.api.sendMessage(chatId, text, rest);
592
+ return { messageId: res.message_id };
593
+ }
594
+ throw err;
595
+ }
596
+ };
597
+ const sendInteractive = async (chatId, text, replyMarkup, opts) => {
598
+ const b = requireLive();
599
+ const params = { reply_markup: replyMarkup };
600
+ if (opts?.html)
601
+ params.parse_mode = "HTML";
602
+ if (opts?.threadId)
603
+ params.message_thread_id = Number(opts.threadId);
604
+ try {
605
+ const res = await b.api.sendMessage(chatId, text, params);
606
+ return { messageId: res.message_id };
607
+ }
608
+ catch (err) {
609
+ if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
610
+ const { message_thread_id: _omit, ...rest } = params;
611
+ const res = await b.api.sendMessage(chatId, text, rest);
612
+ return { messageId: res.message_id };
613
+ }
614
+ throw err;
615
+ }
616
+ };
617
+ const sendMedia = async (chatId, media, opts) => {
618
+ const b = requireLive();
619
+ const { buildTelegramInputFile } = await import("./media.js");
620
+ const input = await buildTelegramInputFile(media);
621
+ const params = {};
622
+ if (opts?.threadId)
623
+ params.message_thread_id = Number(opts.threadId);
624
+ if (media.caption)
625
+ params.caption = media.caption;
626
+ const api = b.api;
627
+ const send = async (p) => {
628
+ switch (media.kind) {
629
+ case "image":
630
+ await api.sendPhoto?.(chatId, input, p);
631
+ return;
632
+ case "video":
633
+ await api.sendVideo?.(chatId, input, p);
634
+ return;
635
+ case "audio":
636
+ await api.sendAudio?.(chatId, input, p);
637
+ return;
638
+ case "voice":
639
+ await api.sendVoice?.(chatId, input, p);
640
+ return;
641
+ case "sticker":
642
+ await api.sendSticker?.(chatId, input, p);
643
+ return;
644
+ default:
645
+ await api.sendDocument?.(chatId, input, p);
646
+ }
647
+ };
648
+ try {
649
+ await send(params);
650
+ }
651
+ catch (err) {
652
+ if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
653
+ const { message_thread_id: _omit, ...rest } = params;
654
+ await send(rest);
655
+ return;
656
+ }
657
+ throw err;
658
+ }
659
+ };
660
+ const react = async (chatId, messageId, emoji) => {
661
+ const b = requireLive();
662
+ if (!b.api.setMessageReaction)
663
+ return; // older API surface — reaction is cosmetic
664
+ const id = Number(messageId);
665
+ if (!Number.isFinite(id))
666
+ return;
667
+ const reaction = emoji ? [{ type: "emoji", emoji }] : [];
668
+ try {
669
+ await b.api.setMessageReaction(chatId, id, reaction);
670
+ }
671
+ catch (err) {
672
+ safeLog("telegram react failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
673
+ }
674
+ };
675
+ const sendPoll = async (chatId, poll, opts) => {
676
+ const b = requireLive();
677
+ if (!b.api.sendPoll)
678
+ throw new Error("Telegram: this bot build cannot send polls.");
679
+ const params = {
680
+ // Telegram defaults `is_anonymous` to true; honour an explicit override.
681
+ is_anonymous: poll.isAnonymous !== false,
682
+ allows_multiple_answers: poll.allowsMultipleAnswers === true,
683
+ };
684
+ if (opts?.threadId)
685
+ params.message_thread_id = Number(opts.threadId);
686
+ try {
687
+ const res = await b.api.sendPoll(chatId, poll.question, poll.options, params);
688
+ return { messageId: res.message_id };
689
+ }
690
+ catch (err) {
691
+ if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
692
+ const { message_thread_id: _omit, ...rest } = params;
693
+ const res = await b.api.sendPoll(chatId, poll.question, poll.options, rest);
694
+ return { messageId: res.message_id };
695
+ }
696
+ throw err;
697
+ }
698
+ };
699
+ const editMessageText = async (chatId, messageId, text, opts) => {
700
+ const b = requireLive();
701
+ if (!b.api.editMessageText)
702
+ throw new Error("Telegram: this bot build cannot edit messages.");
703
+ const id = Number(messageId);
704
+ if (!Number.isFinite(id))
705
+ throw new Error(`Telegram: invalid message id "${messageId}".`);
706
+ const params = {};
707
+ if (opts?.html)
708
+ params.parse_mode = "HTML";
709
+ await b.api.editMessageText(chatId, id, text, params);
710
+ };
711
+ const deleteMessage = async (chatId, messageId) => {
712
+ const b = requireLive();
713
+ if (!b.api.deleteMessage)
714
+ throw new Error("Telegram: this bot build cannot delete messages.");
715
+ const id = Number(messageId);
716
+ if (!Number.isFinite(id))
717
+ throw new Error(`Telegram: invalid message id "${messageId}".`);
718
+ await b.api.deleteMessage(chatId, id);
719
+ };
720
+ const pinMessage = async (chatId, messageId) => {
721
+ const b = requireLive();
722
+ if (!b.api.pinChatMessage)
723
+ throw new Error("Telegram: this bot build cannot pin messages.");
724
+ const id = Number(messageId);
725
+ if (!Number.isFinite(id))
726
+ throw new Error(`Telegram: invalid message id "${messageId}".`);
727
+ // Silent pin (no member notification) — matches the reference behavior.
728
+ await b.api.pinChatMessage(chatId, id, { disable_notification: true });
729
+ };
730
+ const unpinMessage = async (chatId, messageId) => {
731
+ const b = requireLive();
732
+ if (!b.api.unpinChatMessage)
733
+ throw new Error("Telegram: this bot build cannot unpin messages.");
734
+ const params = {};
735
+ if (messageId !== undefined) {
736
+ const id = Number(messageId);
737
+ if (Number.isFinite(id))
738
+ params.message_id = id;
739
+ }
740
+ await b.api.unpinChatMessage(chatId, params);
741
+ };
742
+ const editForumTopic = async (chatId, threadId, name) => {
743
+ const b = bot;
744
+ if (!b || tokenInvalid || !b.api.editForumTopic)
745
+ return; // best-effort — labeling is cosmetic
746
+ const id = Number(threadId);
747
+ if (!Number.isFinite(id))
748
+ return;
749
+ // Telegram caps a forum topic name at 128 chars — clamp defensively.
750
+ const clamped = name.length > 128 ? name.slice(0, 128) : name;
751
+ if (!clamped.trim())
752
+ return;
753
+ try {
754
+ await b.api.editForumTopic(chatId, id, { name: clamped });
755
+ }
756
+ catch (err) {
757
+ safeLog("telegram editForumTopic failed (cosmetic)", {
758
+ error: err instanceof Error ? err.message : String(err),
759
+ });
760
+ }
761
+ };
762
+ const answerCallback = async (callbackId, text) => {
763
+ const b = bot;
764
+ if (!b || tokenInvalid || !b.api.answerCallbackQuery)
765
+ return; // best-effort
766
+ try {
767
+ await b.api.answerCallbackQuery(callbackId, text ? { text } : undefined);
768
+ }
769
+ catch {
770
+ /* ack is cosmetic */
771
+ }
772
+ };
773
+ const getIdentity = async (force) => {
774
+ if (selfIdentity && !force)
775
+ return selfIdentity;
776
+ const b = bot;
777
+ if (!b || tokenInvalid)
778
+ return selfIdentity;
779
+ try {
780
+ const me = await b.api.getMe();
781
+ selfIdentity = me;
782
+ selfId = String(me.id);
783
+ selfUsername = me.username ?? selfUsername;
784
+ return me;
785
+ }
786
+ catch (err) {
787
+ safeLog("telegram getMe (probe) failed", { error: err instanceof Error ? err.message : String(err) });
788
+ return selfIdentity;
789
+ }
790
+ };
791
+ const setCommandMenu = async (commands) => {
792
+ const b = bot;
793
+ if (!b || tokenInvalid || !b.api.setMyCommands)
794
+ return; // best-effort
795
+ if (commands.length === 0)
796
+ return;
797
+ try {
798
+ await b.api.setMyCommands(commands);
799
+ }
800
+ catch (err) {
801
+ safeLog("telegram setMyCommands failed (cosmetic)", {
802
+ error: err instanceof Error ? err.message : String(err),
803
+ });
804
+ }
805
+ };
806
+ const setComposing = async (chatId, state, threadId) => {
807
+ if (state !== "composing")
808
+ return; // Telegram auto-clears typing after ~5s; nothing to send on "paused"
809
+ const b = bot;
810
+ if (!b || tokenInvalid)
811
+ return;
812
+ const params = {};
813
+ if (threadId)
814
+ params.message_thread_id = Number(threadId);
815
+ try {
816
+ await b.api.sendChatAction(chatId, "typing", params);
817
+ }
818
+ catch {
819
+ /* presence is best-effort */
820
+ }
821
+ };
822
+ const close = async () => {
823
+ closed = true;
824
+ connected = false;
825
+ await teardownRunner();
826
+ // Wait for the supervise loop to unwind, but never hang on it — teardown
827
+ // resolves the active runner's task() so the loop sees `closed` and
828
+ // returns promptly; the timeout is pure defense-in-depth.
829
+ try {
830
+ await Promise.race([
831
+ loopPromise ?? Promise.resolve(),
832
+ new Promise((resolve) => setTimeout(resolve, 5_000).unref?.()),
833
+ ]);
834
+ }
835
+ catch {
836
+ /* loop already settled */
837
+ }
838
+ };
839
+ return {
840
+ selfId: () => selfId,
841
+ selfUsername: () => selfUsername,
842
+ connectedAt: () => connectedAtMs,
843
+ isConnected: () => connected,
844
+ isTokenInvalid: () => tokenInvalid,
845
+ sendText,
846
+ sendInteractive,
847
+ sendMedia,
848
+ sendPoll,
849
+ react,
850
+ editMessageText,
851
+ deleteMessage,
852
+ pinMessage,
853
+ unpinMessage,
854
+ editForumTopic,
855
+ answerCallback,
856
+ getIdentity,
857
+ setCommandMenu,
858
+ feedUpdate,
859
+ mode: () => mode,
860
+ setComposing,
861
+ markRead: async () => { },
862
+ close,
863
+ };
864
+ }
865
+ //# sourceMappingURL=connection.js.map