grok-telegram-bot 2.4.0 → 2.6.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 (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Allowlisted sibling Telegram bots — optional command catalogs + invoke
3
+ * slash commands like lightweight MCP tools.
4
+ *
5
+ * Reply capture rules:
6
+ * - Only messages/edits from a bot we *just* triggered (pending wait) count.
7
+ * - Prefer replies to our trigger message_id; also accept non-reply messages
8
+ * from that bot in the same chat/thread after the trigger was sent.
9
+ * - Streaming bots often edit one message: we listen for edited_message and
10
+ * wait until activity settles (idle window) before resolving — Bot API does
11
+ * not expose other bots' typing indicators, so settle-after-idle is the
12
+ * practical equivalent of "wait until typing finishes".
13
+ * - Hard timeout: if any content arrived, return it as partial; else error.
14
+ * Session always continues (not a Done for the Grok turn).
15
+ */
16
+ import type { Api, Bot, Context } from "grammy";
17
+ import type { AppConfig } from "../config.js";
18
+ import { createLogger } from "../logger.js";
19
+ import { normalizeUsername } from "../render/telegram-bridge.js";
20
+
21
+ const log = createLogger("telegram-bots");
22
+
23
+ export interface SiblingBotCommand {
24
+ /** Command without leading slash. */
25
+ command: string;
26
+ /** Optional short description. */
27
+ description?: string;
28
+ }
29
+
30
+ export interface SiblingBotInfo {
31
+ username: string;
32
+ id?: number;
33
+ inGroup: boolean;
34
+ status?: string;
35
+ note?: string;
36
+ /** From TELEGRAM_BOT_COMMANDS when configured. */
37
+ commands: SiblingBotCommand[];
38
+ }
39
+
40
+ export interface BotWaitResult {
41
+ text: string;
42
+ /** True when hard timeout fired with incomplete stream content. */
43
+ partial: boolean;
44
+ }
45
+
46
+ interface PendingWait {
47
+ chatId: number;
48
+ botId: number;
49
+ messageThreadId?: number;
50
+ /** Our /cmd@bot message id — preferred reply target. */
51
+ triggerMessageId?: number;
52
+ /** Only accept content after this timestamp (ms). */
53
+ startedAt: number;
54
+ settleMs: number;
55
+ chunks: Map<number, string>; // message_id → latest text
56
+ hardTimer: NodeJS.Timeout;
57
+ settleTimer?: NodeJS.Timeout;
58
+ resolve: (result: BotWaitResult) => void;
59
+ reject: (err: Error) => void;
60
+ settled: boolean;
61
+ }
62
+
63
+ /**
64
+ * Catalog + reply waiter for allowlisted bots.
65
+ * Register once with {@link attachToBot} so message/edit updates fulfill waits.
66
+ */
67
+ export class TelegramBotService {
68
+ private readonly cache = new Map<string, SiblingBotInfo>();
69
+ private readonly pending: PendingWait[] = [];
70
+
71
+ constructor(
72
+ private readonly api: Api,
73
+ private readonly cfg: AppConfig,
74
+ ) {}
75
+
76
+ get allowedUsernames(): string[] {
77
+ return this.cfg.allowedTelegramBots;
78
+ }
79
+
80
+ /** Commands configured for a bot (empty if none). */
81
+ commandsFor(username: string): SiblingBotCommand[] {
82
+ const u = normalizeUsername(username);
83
+ return this.cfg.telegramBotCommands[u] ?? [];
84
+ }
85
+
86
+ /** Wire message + edit middleware so bot_command waits can resolve. */
87
+ attachToBot(bot: Bot): void {
88
+ bot.on("message", (ctx, next) => {
89
+ this.onBotContent(ctx, "message");
90
+ return next();
91
+ });
92
+ bot.on("edited_message", (ctx, next) => {
93
+ this.onBotContent(ctx, "edited_message");
94
+ return next();
95
+ });
96
+ }
97
+
98
+ isAllowed(username: string): boolean {
99
+ const u = normalizeUsername(username);
100
+ return this.cfg.allowedTelegramBots.includes(u);
101
+ }
102
+
103
+ /** Probe each allowlisted bot (cached per process). */
104
+ async listBots(force = false): Promise<SiblingBotInfo[]> {
105
+ const out: SiblingBotInfo[] = [];
106
+ for (const username of this.cfg.allowedTelegramBots) {
107
+ if (!force && this.cache.has(username)) {
108
+ // Refresh commands from live config (catalog can be static; membership cached).
109
+ const cached = this.cache.get(username)!;
110
+ cached.commands = this.commandsFor(username);
111
+ out.push(cached);
112
+ continue;
113
+ }
114
+ const info = await this.probeOne(username);
115
+ this.cache.set(username, info);
116
+ out.push(info);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Send `/command@bot args` in the target chat and wait until the bot finishes
123
+ * streaming (idle settle) or hard timeout. Only content from that bot after
124
+ * our trigger is collected — unrelated bot chatter is ignored.
125
+ */
126
+ async invokeCommand(opts: {
127
+ bot: string;
128
+ command: string;
129
+ args?: string;
130
+ chatId: number;
131
+ messageThreadId?: number;
132
+ }): Promise<
133
+ | { ok: true; reply: string; partial: boolean }
134
+ | {
135
+ ok: false;
136
+ error: string;
137
+ kind: "timeout" | "send" | "resolve" | "not_allowed" | "unknown_command";
138
+ partialReply?: string;
139
+ }
140
+ > {
141
+ const username = normalizeUsername(opts.bot);
142
+ if (!this.isAllowed(username)) {
143
+ return {
144
+ ok: false,
145
+ kind: "not_allowed",
146
+ error: `@${username} is not in ALLOWED_TELEGRAM_BOTS`,
147
+ };
148
+ }
149
+
150
+ const cmd = opts.command.replace(/^\//, "").trim().toLowerCase();
151
+ if (!cmd) {
152
+ return { ok: false, kind: "unknown_command", error: "empty command" };
153
+ }
154
+
155
+ // Optional catalog: unknown commands still attempt (bot may accept them) but
156
+ // we annotate so the agent can adjust. We do NOT hard-fail — a dead command
157
+ // becomes a timeout/error result, not a Done for the Grok session.
158
+ const catalog = this.commandsFor(username);
159
+ const catalogHint =
160
+ catalog.length > 0 && !catalog.some((c) => c.command === cmd)
161
+ ? ` (not in TELEGRAM_BOT_COMMANDS catalog for @${username}: ${catalog.map((c) => "/" + c.command).join(", ")})`
162
+ : "";
163
+
164
+ const info = (await this.listBots(true)).find((b) => b.username === username);
165
+ if (!info?.id) {
166
+ return {
167
+ ok: false,
168
+ kind: "resolve",
169
+ error: `Could not resolve @${username} (public username required)`,
170
+ };
171
+ }
172
+
173
+ const args = (opts.args ?? "").trim();
174
+ const text = args
175
+ ? `/${cmd}@${username} ${args}`.slice(0, 4000)
176
+ : `/${cmd}@${username}`;
177
+
178
+ const extra: Record<string, unknown> = {};
179
+ // Omit General (1) — Bot API rejects message_thread_id=1.
180
+ if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
181
+ extra.message_thread_id = opts.messageThreadId;
182
+ }
183
+
184
+ // Register waiter BEFORE send so a fast reply cannot race past us.
185
+ const replyPromise = this.waitForReply({
186
+ chatId: opts.chatId,
187
+ botId: info.id,
188
+ messageThreadId: opts.messageThreadId,
189
+ timeoutMs: this.cfg.telegramBotReplyTimeoutMs,
190
+ settleMs: this.cfg.telegramBotSettleMs,
191
+ });
192
+
193
+ try {
194
+ const sent = await this.api.sendMessage(opts.chatId, text, extra);
195
+ const triggerMessageId = (sent as { message_id?: number }).message_id;
196
+ if (triggerMessageId !== undefined) {
197
+ this.setTriggerMessageId(opts.chatId, info.id, triggerMessageId);
198
+ }
199
+ } catch (e) {
200
+ this.cancelWait(opts.chatId, info.id, "send failed");
201
+ return {
202
+ ok: false,
203
+ kind: "send",
204
+ error: `send failed: ${(e as Error).message}${catalogHint}`,
205
+ };
206
+ }
207
+
208
+ try {
209
+ const result = await replyPromise;
210
+ return {
211
+ ok: true,
212
+ reply: result.text,
213
+ partial: result.partial,
214
+ };
215
+ } catch (e) {
216
+ const msg = (e as Error).message ?? String(e);
217
+ const kind = /timed out/i.test(msg) ? "timeout" : "resolve";
218
+ return {
219
+ ok: false,
220
+ kind,
221
+ error: `${msg}${catalogHint}`,
222
+ };
223
+ }
224
+ }
225
+
226
+ private setTriggerMessageId(chatId: number, botId: number, messageId: number): void {
227
+ // Prefer the most recently added wait without a trigger (LIFO for sequential invokes).
228
+ for (let i = this.pending.length - 1; i >= 0; i--) {
229
+ const p = this.pending[i]!;
230
+ if (p.chatId === chatId && p.botId === botId && !p.settled && p.triggerMessageId === undefined) {
231
+ p.triggerMessageId = messageId;
232
+ return;
233
+ }
234
+ }
235
+ }
236
+
237
+ /** Drop pending wait(s) for this bot so the promise settles (send failure). */
238
+ private cancelWait(chatId: number, botId: number, reason: string): void {
239
+ for (let i = this.pending.length - 1; i >= 0; i--) {
240
+ const p = this.pending[i]!;
241
+ if (p.chatId === chatId && p.botId === botId && !p.settled) {
242
+ this.finishWait(p, i, "reject", new Error(reason));
243
+ }
244
+ }
245
+ }
246
+
247
+ private async probeOne(username: string): Promise<SiblingBotInfo> {
248
+ const commands = this.commandsFor(username);
249
+ const base: SiblingBotInfo = { username, inGroup: false, commands };
250
+ let id: number | undefined;
251
+ try {
252
+ const chat = await this.api.getChat(`@${username}`);
253
+ id = (chat as { id?: number }).id;
254
+ base.id = id;
255
+ } catch (e) {
256
+ base.note = `getChat(@${username}) failed: ${(e as Error).message}`;
257
+ return base;
258
+ }
259
+
260
+ const groupId = this.cfg.topicGroupId;
261
+ if (groupId === undefined || id === undefined) {
262
+ base.note =
263
+ groupId === undefined
264
+ ? "TOPIC_GROUP_ID unset — cannot verify group membership"
265
+ : base.note;
266
+ return base;
267
+ }
268
+
269
+ try {
270
+ const member = await this.api.getChatMember(groupId, id);
271
+ const status = (member as { status?: string }).status ?? "unknown";
272
+ base.status = status;
273
+ base.inGroup = !["left", "kicked"].includes(status);
274
+ if (!base.inGroup) base.note = `not in group (status=${status})`;
275
+ } catch (e) {
276
+ base.note = `getChatMember failed: ${(e as Error).message}`;
277
+ }
278
+ return base;
279
+ }
280
+
281
+ private waitForReply(opts: {
282
+ chatId: number;
283
+ botId: number;
284
+ messageThreadId?: number;
285
+ timeoutMs: number;
286
+ settleMs: number;
287
+ }): Promise<BotWaitResult> {
288
+ return new Promise((resolve, reject) => {
289
+ const entry: PendingWait = {
290
+ chatId: opts.chatId,
291
+ botId: opts.botId,
292
+ messageThreadId: opts.messageThreadId,
293
+ startedAt: Date.now(),
294
+ settleMs: Math.max(400, opts.settleMs),
295
+ chunks: new Map(),
296
+ hardTimer: setTimeout(() => {
297
+ if (entry.settled) return;
298
+ const text = this.joinChunks(entry);
299
+ // Partial content on timeout → resolve so the agent still gets what arrived.
300
+ if (text) {
301
+ this.finishWait(entry, this.pending.indexOf(entry), "resolve", {
302
+ text,
303
+ partial: true,
304
+ });
305
+ return;
306
+ }
307
+ this.finishWait(
308
+ entry,
309
+ this.pending.indexOf(entry),
310
+ "reject",
311
+ new Error(
312
+ `Timed out after ${opts.timeoutMs}ms waiting for bot reply (offline, privacy mode, or unknown command?)`,
313
+ ),
314
+ );
315
+ }, opts.timeoutMs),
316
+ resolve,
317
+ reject,
318
+ settled: false,
319
+ };
320
+ this.pending.push(entry);
321
+ log.debug(
322
+ `waiting for bot ${opts.botId} in chat ${opts.chatId} ` +
323
+ `(timeout ${opts.timeoutMs}ms, settle ${opts.settleMs}ms)`,
324
+ );
325
+ });
326
+ }
327
+
328
+ private finishWait(
329
+ entry: PendingWait,
330
+ index: number,
331
+ mode: "resolve" | "reject",
332
+ value: BotWaitResult | Error,
333
+ ): void {
334
+ if (entry.settled) return;
335
+ entry.settled = true;
336
+ clearTimeout(entry.hardTimer);
337
+ if (entry.settleTimer) clearTimeout(entry.settleTimer);
338
+ if (index >= 0) this.pending.splice(index, 1);
339
+ else {
340
+ const i = this.pending.indexOf(entry);
341
+ if (i >= 0) this.pending.splice(i, 1);
342
+ }
343
+ if (mode === "resolve") entry.resolve(value as BotWaitResult);
344
+ else entry.reject(value as Error);
345
+ }
346
+
347
+ private joinChunks(entry: PendingWait): string {
348
+ // Preserve message order by message_id (Telegram ids increase).
349
+ const parts = [...entry.chunks.entries()]
350
+ .sort((a, b) => a[0] - b[0])
351
+ .map(([, t]) => t.trim())
352
+ .filter(Boolean);
353
+ return parts.join("\n\n").slice(0, 8000);
354
+ }
355
+
356
+ private scheduleSettle(entry: PendingWait): void {
357
+ if (entry.settled) return;
358
+ if (entry.settleTimer) clearTimeout(entry.settleTimer);
359
+ entry.settleTimer = setTimeout(() => {
360
+ if (entry.settled || entry.chunks.size === 0) return;
361
+ const text = this.joinChunks(entry);
362
+ if (!text) return;
363
+ this.finishWait(entry, this.pending.indexOf(entry), "resolve", {
364
+ text,
365
+ partial: false,
366
+ });
367
+ }, entry.settleMs);
368
+ }
369
+
370
+ private onBotContent(ctx: Context, kind: "message" | "edited_message"): void {
371
+ if (this.pending.length === 0) return;
372
+ const msg = kind === "edited_message" ? ctx.editedMessage : ctx.message;
373
+ if (!msg) return;
374
+ const from = msg.from;
375
+ if (!from?.is_bot) return;
376
+
377
+ const chatId = msg.chat.id;
378
+ const botId = from.id;
379
+ const threadId = msg.message_thread_id;
380
+ const messageId = msg.message_id;
381
+ const text = extractMessageText(msg);
382
+ if (!text) return;
383
+
384
+ const replyTo = msg.reply_to_message?.message_id;
385
+ const msgDateMs = (msg.date ?? 0) * 1000;
386
+ const editDateMs =
387
+ typeof (msg as { edit_date?: number }).edit_date === "number"
388
+ ? (msg as { edit_date: number }).edit_date * 1000
389
+ : 0;
390
+
391
+ for (const p of this.pending) {
392
+ if (p.settled) continue;
393
+ if (p.chatId !== chatId || p.botId !== botId) continue;
394
+
395
+ // Strict thread match when the wait is topic-scoped (forum).
396
+ if (p.messageThreadId !== undefined && threadId !== p.messageThreadId) {
397
+ continue;
398
+ }
399
+
400
+ if (
401
+ !shouldAcceptBotContent({
402
+ kind,
403
+ alreadyTracked: p.chunks.has(messageId),
404
+ startedAt: p.startedAt,
405
+ msgDateMs,
406
+ editDateMs,
407
+ triggerMessageId: p.triggerMessageId,
408
+ replyToMessageId: replyTo,
409
+ })
410
+ ) {
411
+ continue;
412
+ }
413
+
414
+ p.chunks.set(messageId, text);
415
+ this.scheduleSettle(p);
416
+ log.debug(
417
+ `bot ${botId} ${kind} #${messageId} (${text.length} chars) — settle in ${p.settleMs}ms`,
418
+ );
419
+ return;
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Pure filter: should this update count toward a pending sibling-bot wait?
426
+ * Exported for unit tests.
427
+ */
428
+ export function shouldAcceptBotContent(opts: {
429
+ kind: "message" | "edited_message";
430
+ alreadyTracked: boolean;
431
+ startedAt: number;
432
+ msgDateMs: number;
433
+ editDateMs: number;
434
+ triggerMessageId?: number;
435
+ replyToMessageId?: number;
436
+ }): boolean {
437
+ // Edits of messages we already collected always win (streaming / SSE bots).
438
+ if (opts.kind === "edited_message" && opts.alreadyTracked) return true;
439
+
440
+ // Explicit reply to something other than our trigger → not our call.
441
+ if (
442
+ opts.triggerMessageId !== undefined &&
443
+ opts.replyToMessageId !== undefined &&
444
+ opts.replyToMessageId !== opts.triggerMessageId
445
+ ) {
446
+ return false;
447
+ }
448
+
449
+ // Prefer content that replies to our trigger.
450
+ if (
451
+ opts.triggerMessageId !== undefined &&
452
+ opts.replyToMessageId === opts.triggerMessageId
453
+ ) {
454
+ return true;
455
+ }
456
+
457
+ // New message: drop stale chatter from before we started waiting (2s slack).
458
+ if (opts.kind === "message") {
459
+ if (opts.msgDateMs > 0 && opts.msgDateMs + 2000 < opts.startedAt) return false;
460
+ return true;
461
+ }
462
+
463
+ // First sighting of a message via edit only (no prior chunk): require it looks
464
+ // "new enough" — use edit_date or date so we do not steal ancient bot posts.
465
+ const effective = opts.editDateMs || opts.msgDateMs;
466
+ if (effective > 0 && effective + 2000 < opts.startedAt) return false;
467
+ return true;
468
+ }
469
+
470
+ function extractMessageText(msg: {
471
+ text?: string;
472
+ caption?: string;
473
+ photo?: unknown;
474
+ document?: unknown;
475
+ video?: unknown;
476
+ audio?: unknown;
477
+ voice?: unknown;
478
+ sticker?: unknown;
479
+ animation?: unknown;
480
+ }): string {
481
+ const t = (msg.text || msg.caption || "").trim();
482
+ if (t) return t;
483
+ // Media-only replies still count so settle can finish (not hang until timeout).
484
+ if (
485
+ msg.photo ||
486
+ msg.document ||
487
+ msg.video ||
488
+ msg.audio ||
489
+ msg.voice ||
490
+ msg.sticker ||
491
+ msg.animation
492
+ ) {
493
+ return "[media]";
494
+ }
495
+ return "";
496
+ }
@@ -10,26 +10,65 @@ import { toTelegramMarkdown } from "../render/markdown.js";
10
10
  const log = createLogger("tg:io");
11
11
  const MAX_RETRIES = 3;
12
12
 
13
- async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
14
- let attempt = 0;
13
+ const TRANSIENT_NET =
14
+ /econnreset|econnrefused|etimedout|eai_again|socket hang ?up|fetch failed|network|temporarily unavailable/i;
15
+
16
+ export type TelegramRetryOptions = {
17
+ /** Max attempts after the first try for 429 (default 3). */
18
+ maxRateLimitRetries?: number;
19
+ /** Max attempts after the first try for transient network errors (default 0 = off). */
20
+ maxNetworkRetries?: number;
21
+ /** Logger label for debug lines. */
22
+ label?: string;
23
+ };
24
+
25
+ /**
26
+ * Retry a Telegram API call on 429 (respect `retry_after`) and optionally on
27
+ * transient network failures. Used by safe send/edit and by long forum setup.
28
+ */
29
+ export async function withTelegramRetry<T>(
30
+ fn: () => Promise<T>,
31
+ opts: TelegramRetryOptions = {},
32
+ ): Promise<T> {
33
+ const max429 = opts.maxRateLimitRetries ?? MAX_RETRIES;
34
+ const maxNet = opts.maxNetworkRetries ?? 0;
35
+ const label = opts.label ?? "tg";
36
+ let rateAttempts = 0;
37
+ let netAttempts = 0;
15
38
  for (;;) {
16
39
  try {
17
40
  return await fn();
18
41
  } catch (err) {
19
42
  if (err instanceof GrammyError && err.error_code === 429) {
20
43
  const wait = (err.parameters?.retry_after ?? 1) * 1000 + 250;
21
- if (attempt++ < MAX_RETRIES) {
22
- log.debug(`429 rate limited, waiting ${wait}ms`);
44
+ if (rateAttempts++ < max429) {
45
+ log.debug(`${label}: 429 rate limited, waiting ${wait}ms (try ${rateAttempts}/${max429})`);
23
46
  await sleep(wait);
24
47
  continue;
25
48
  }
49
+ } else if (maxNet > 0 && isTransientNetworkError(err) && netAttempts++ < maxNet) {
50
+ const wait = Math.min(30_000, 1000 * 2 ** (netAttempts - 1));
51
+ log.debug(`${label}: transient network error, retry in ${wait}ms (try ${netAttempts}/${maxNet})`);
52
+ await sleep(wait);
53
+ continue;
26
54
  }
27
55
  throw err;
28
56
  }
29
57
  }
30
58
  }
31
59
 
32
- /** Send a message as MarkdownV2, falling back to plain text on parse errors. */
60
+ function isTransientNetworkError(err: unknown): boolean {
61
+ if (!err || typeof err !== "object") return false;
62
+ const msg = String((err as Error).message ?? err);
63
+ const code = String((err as { code?: string }).code ?? "");
64
+ return TRANSIENT_NET.test(msg) || TRANSIENT_NET.test(code);
65
+ }
66
+
67
+ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
68
+ return withTelegramRetry(fn, { maxRateLimitRetries: MAX_RETRIES });
69
+ }
70
+
71
+ /** Send a message as MarkdownV2, falling back to demoted plain text on parse errors. */
33
72
  export async function safeSend(
34
73
  api: Api,
35
74
  chatId: number,
@@ -44,7 +83,10 @@ export async function safeSend(
44
83
  return msg.message_id;
45
84
  } catch (err) {
46
85
  if (isParseError(err)) {
47
- const msg = await withRetry(() => api.sendMessage(chatId, plain, extra));
86
+ // Do not send raw markdown — Telegram clients soft-render ** and ``` in
87
+ // plain messages and Windows paths look broken (e.g. **Edit C:** wrap).
88
+ const demoted = demoteMarkdownForPlain(plain);
89
+ const msg = await withRetry(() => api.sendMessage(chatId, demoted, extra));
48
90
  return msg.message_id;
49
91
  }
50
92
  log.warn("sendMessage failed:", (err as Error).message);
@@ -52,7 +94,7 @@ export async function safeSend(
52
94
  }
53
95
  }
54
96
 
55
- /** Edit a message as MarkdownV2, falling back to plain text on parse errors. */
97
+ /** Edit a message as MarkdownV2, falling back to demoted plain text on parse errors. */
56
98
  export async function safeEdit(
57
99
  api: Api,
58
100
  chatId: number,
@@ -68,7 +110,7 @@ export async function safeEdit(
68
110
  if (isNotModified(err)) return;
69
111
  if (isParseError(err)) {
70
112
  try {
71
- await withRetry(() => api.editMessageText(chatId, messageId, plain));
113
+ await withRetry(() => api.editMessageText(chatId, messageId, demoteMarkdownForPlain(plain)));
72
114
  } catch (e2) {
73
115
  if (!isNotModified(e2)) log.debug("plain edit failed:", (e2 as Error).message);
74
116
  }
@@ -78,6 +120,47 @@ export async function safeEdit(
78
120
  }
79
121
  }
80
122
 
123
+ /**
124
+ * When MarkdownV2 parse fails, send readable plain text without `**` / fences
125
+ * that clients soft-render into broken bold around Windows paths.
126
+ */
127
+ export function demoteMarkdownForPlain(src: string): string {
128
+ if (!src) return src;
129
+ let s = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
130
+ // Fenced blocks → indented plain body (drop language tag). Same tick count open/close.
131
+ s = s.replace(/^[ \t]*(`{3,})([^\n]*)\n([\s\S]*?)^[ \t]*\1[ \t]*$/gm, (_m, _ticks, _lang, body: string) => {
132
+ const lines = String(body).replace(/\n$/, "").split("\n");
133
+ return lines.map((l) => (l ? " " + l : "")).join("\n");
134
+ });
135
+ // Unclosed trailing fence only at end of message (streaming mid-fence).
136
+ s = s.replace(/^[ \t]*`{3,}[^\n]*\n([\s\S]*)$/m, (full, body: string, offset: number) => {
137
+ // Only treat as unclosed if this match reaches the true end of the string.
138
+ if (offset + full.length < s.length) return full;
139
+ return String(body)
140
+ .split("\n")
141
+ .map((l) => (l ? " " + l : ""))
142
+ .join("\n");
143
+ });
144
+ // Inline code → keep content.
145
+ s = s.replace(/`([^`\n]+)`/g, "$1");
146
+ // Bold / italic / strike markers (repeat until stable for adjacent spans).
147
+ for (let i = 0; i < 3; i++) {
148
+ const next = s
149
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
150
+ .replace(/__([^_]+)__/g, "$1")
151
+ .replace(/~~([^~]+)~~/g, "$1");
152
+ if (next === s) break;
153
+ s = next;
154
+ }
155
+ // Leftover emphasis markers (including broken **Edit C:** style).
156
+ s = s.replace(/\*\*/g, "");
157
+ s = s.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "$1");
158
+ s = s.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "$1");
159
+ // Collapse leftover fence ticks.
160
+ s = s.replace(/`{3,}/g, "");
161
+ return s.replace(/\n{4,}/g, "\n\n\n").trimEnd();
162
+ }
163
+
81
164
  function isParseError(err: unknown): boolean {
82
165
  return err instanceof GrammyError && /can't parse entities|parse entities/i.test(err.description);
83
166
  }
@@ -97,9 +180,13 @@ export async function sendMarkdownDoc(
97
180
  api: Api,
98
181
  chatId: number,
99
182
  rawMarkdown: string,
100
- opts?: { loud?: boolean },
183
+ opts?: { loud?: boolean; messageThreadId?: number },
101
184
  ): Promise<void> {
102
- const extra = opts?.loud ? { disable_notification: false } : {};
185
+ const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
186
+ // Omit General (1) — Bot API rejects message_thread_id=1.
187
+ if (opts?.messageThreadId !== undefined && opts.messageThreadId !== 1) {
188
+ extra.message_thread_id = opts.messageThreadId;
189
+ }
103
190
  const rendered = toTelegramMarkdown(rawMarkdown);
104
191
  const mdChunks = chunkMarkdown(rendered);
105
192
  const plainChunks = chunkMarkdown(rawMarkdown);
package/src/cli.ts CHANGED
@@ -83,12 +83,14 @@ async function main(): Promise<void> {
83
83
 
84
84
  case "logs":
85
85
  printLogs(arg ? Number(arg) || 100 : 100);
86
+ process.exit(0);
86
87
  break;
87
88
 
88
89
  case "help":
89
90
  case "--help":
90
91
  case "-h":
91
92
  console.log(HELP);
93
+ process.exit(0);
92
94
  break;
93
95
 
94
96
  default: