aegis-bridge 0.1.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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
  4. package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
  5. package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/api-contracts.d.ts +229 -0
  8. package/dist/api-contracts.js +7 -0
  9. package/dist/api-contracts.typecheck.d.ts +14 -0
  10. package/dist/api-contracts.typecheck.js +1 -0
  11. package/dist/api-error-envelope.d.ts +15 -0
  12. package/dist/api-error-envelope.js +80 -0
  13. package/dist/auth.d.ts +87 -0
  14. package/dist/auth.js +276 -0
  15. package/dist/channels/index.d.ts +8 -0
  16. package/dist/channels/index.js +8 -0
  17. package/dist/channels/manager.d.ts +47 -0
  18. package/dist/channels/manager.js +115 -0
  19. package/dist/channels/telegram-style.d.ts +118 -0
  20. package/dist/channels/telegram-style.js +202 -0
  21. package/dist/channels/telegram.d.ts +91 -0
  22. package/dist/channels/telegram.js +1518 -0
  23. package/dist/channels/types.d.ts +77 -0
  24. package/dist/channels/types.js +8 -0
  25. package/dist/channels/webhook.d.ts +60 -0
  26. package/dist/channels/webhook.js +216 -0
  27. package/dist/cli.d.ts +8 -0
  28. package/dist/cli.js +252 -0
  29. package/dist/config.d.ts +90 -0
  30. package/dist/config.js +214 -0
  31. package/dist/consensus.d.ts +16 -0
  32. package/dist/consensus.js +19 -0
  33. package/dist/continuation-pointer.d.ts +11 -0
  34. package/dist/continuation-pointer.js +65 -0
  35. package/dist/diagnostics.d.ts +27 -0
  36. package/dist/diagnostics.js +95 -0
  37. package/dist/error-categories.d.ts +39 -0
  38. package/dist/error-categories.js +73 -0
  39. package/dist/events.d.ts +133 -0
  40. package/dist/events.js +389 -0
  41. package/dist/fault-injection.d.ts +29 -0
  42. package/dist/fault-injection.js +115 -0
  43. package/dist/file-utils.d.ts +2 -0
  44. package/dist/file-utils.js +37 -0
  45. package/dist/handshake.d.ts +60 -0
  46. package/dist/handshake.js +124 -0
  47. package/dist/hook-settings.d.ts +80 -0
  48. package/dist/hook-settings.js +272 -0
  49. package/dist/hook.d.ts +19 -0
  50. package/dist/hook.js +231 -0
  51. package/dist/hooks.d.ts +32 -0
  52. package/dist/hooks.js +364 -0
  53. package/dist/jsonl-watcher.d.ts +59 -0
  54. package/dist/jsonl-watcher.js +166 -0
  55. package/dist/logger.d.ts +35 -0
  56. package/dist/logger.js +65 -0
  57. package/dist/mcp-server.d.ts +123 -0
  58. package/dist/mcp-server.js +869 -0
  59. package/dist/memory-bridge.d.ts +27 -0
  60. package/dist/memory-bridge.js +137 -0
  61. package/dist/memory-routes.d.ts +3 -0
  62. package/dist/memory-routes.js +100 -0
  63. package/dist/metrics.d.ts +126 -0
  64. package/dist/metrics.js +286 -0
  65. package/dist/model-router.d.ts +53 -0
  66. package/dist/model-router.js +150 -0
  67. package/dist/monitor.d.ts +103 -0
  68. package/dist/monitor.js +820 -0
  69. package/dist/path-utils.d.ts +11 -0
  70. package/dist/path-utils.js +21 -0
  71. package/dist/permission-evaluator.d.ts +10 -0
  72. package/dist/permission-evaluator.js +48 -0
  73. package/dist/permission-guard.d.ts +51 -0
  74. package/dist/permission-guard.js +196 -0
  75. package/dist/permission-request-manager.d.ts +12 -0
  76. package/dist/permission-request-manager.js +36 -0
  77. package/dist/permission-routes.d.ts +7 -0
  78. package/dist/permission-routes.js +28 -0
  79. package/dist/pipeline.d.ts +97 -0
  80. package/dist/pipeline.js +291 -0
  81. package/dist/process-utils.d.ts +4 -0
  82. package/dist/process-utils.js +73 -0
  83. package/dist/question-manager.d.ts +54 -0
  84. package/dist/question-manager.js +80 -0
  85. package/dist/retry.d.ts +11 -0
  86. package/dist/retry.js +34 -0
  87. package/dist/safe-json.d.ts +12 -0
  88. package/dist/safe-json.js +22 -0
  89. package/dist/screenshot.d.ts +28 -0
  90. package/dist/screenshot.js +60 -0
  91. package/dist/server.d.ts +10 -0
  92. package/dist/server.js +1973 -0
  93. package/dist/session-cleanup.d.ts +18 -0
  94. package/dist/session-cleanup.js +11 -0
  95. package/dist/session.d.ts +379 -0
  96. package/dist/session.js +1568 -0
  97. package/dist/shutdown-utils.d.ts +5 -0
  98. package/dist/shutdown-utils.js +24 -0
  99. package/dist/signal-cleanup-helper.d.ts +48 -0
  100. package/dist/signal-cleanup-helper.js +117 -0
  101. package/dist/sse-limiter.d.ts +47 -0
  102. package/dist/sse-limiter.js +61 -0
  103. package/dist/sse-writer.d.ts +31 -0
  104. package/dist/sse-writer.js +94 -0
  105. package/dist/ssrf.d.ts +102 -0
  106. package/dist/ssrf.js +267 -0
  107. package/dist/startup.d.ts +6 -0
  108. package/dist/startup.js +162 -0
  109. package/dist/suppress.d.ts +33 -0
  110. package/dist/suppress.js +79 -0
  111. package/dist/swarm-monitor.d.ts +117 -0
  112. package/dist/swarm-monitor.js +300 -0
  113. package/dist/template-store.d.ts +45 -0
  114. package/dist/template-store.js +142 -0
  115. package/dist/terminal-parser.d.ts +16 -0
  116. package/dist/terminal-parser.js +346 -0
  117. package/dist/tmux-capture-cache.d.ts +18 -0
  118. package/dist/tmux-capture-cache.js +34 -0
  119. package/dist/tmux.d.ts +183 -0
  120. package/dist/tmux.js +906 -0
  121. package/dist/tool-registry.d.ts +40 -0
  122. package/dist/tool-registry.js +83 -0
  123. package/dist/transcript.d.ts +63 -0
  124. package/dist/transcript.js +284 -0
  125. package/dist/utils/circular-buffer.d.ts +11 -0
  126. package/dist/utils/circular-buffer.js +37 -0
  127. package/dist/utils/redact-headers.d.ts +13 -0
  128. package/dist/utils/redact-headers.js +54 -0
  129. package/dist/validation.d.ts +406 -0
  130. package/dist/validation.js +415 -0
  131. package/dist/verification.d.ts +2 -0
  132. package/dist/verification.js +72 -0
  133. package/dist/worktree-lookup.d.ts +24 -0
  134. package/dist/worktree-lookup.js +71 -0
  135. package/dist/ws-terminal.d.ts +32 -0
  136. package/dist/ws-terminal.js +348 -0
  137. package/package.json +83 -0
@@ -0,0 +1,1518 @@
1
+ /**
2
+ * channels/telegram.ts — Telegram notification channel.
3
+ *
4
+ * Creates one topic per CC session in a Telegram supergroup.
5
+ * Bidirectional: reads replies from topics and fires inbound commands.
6
+ *
7
+ * Formatting: HTML parse_mode with structured, clean messages.
8
+ */
9
+ import { homedir } from 'node:os';
10
+ import { esc, bold, code, italic, quickUpdate, taskComplete, alert as styleAlert, } from './telegram-style.js';
11
+ /** Call Telegram Bot API with retry on 429. */
12
+ // ── HTML Helpers ────────────────────────────────────────────────────────────
13
+ function truncate(text, maxLen) {
14
+ if (text.length <= maxLen)
15
+ return text;
16
+ return text.slice(0, maxLen - 1) + '…';
17
+ }
18
+ function elapsed(ms) {
19
+ const s = Math.floor(ms / 1000);
20
+ if (s < 60)
21
+ return `${s}s`;
22
+ const m = Math.floor(s / 60);
23
+ if (m < 60)
24
+ return `${m}m ${s % 60}s`;
25
+ const h = Math.floor(m / 60);
26
+ return `${h}h ${m % 60}m`;
27
+ }
28
+ function shortPath(path) {
29
+ const normalized = path.replace(/\\/g, '/');
30
+ // Keep only filename or last 2 segments
31
+ const parts = normalized.replace(/^\//, '').split('/');
32
+ if (parts.length <= 2)
33
+ return parts.join('/');
34
+ return '…/' + parts.slice(-2).join('/');
35
+ }
36
+ function shortenHomePath(workDir) {
37
+ const normalized = workDir.replace(/\\/g, '/');
38
+ const home = homedir().replace(/\\/g, '/').replace(/\/+$/, '');
39
+ if (normalized === home)
40
+ return '~';
41
+ if (normalized.startsWith(`${home}/`))
42
+ return `~${normalized.slice(home.length)}`;
43
+ return normalized;
44
+ }
45
+ /**
46
+ * Strip Claude Code internal XML tags from assistant messages.
47
+ * These tags (local-command-*, antml:*, etc.) are CC's internal markup
48
+ * and must NEVER be shown to the user on Telegram.
49
+ *
50
+ * Some tags carry useful info — extract and convert them:
51
+ * <local-command-stdout>text</local-command-stdout> → keep "text"
52
+ * <command-name>/plan</command-name> → "🔄 Plan mode enabled"
53
+ * <command-name>/compact</command-name> → "🔄 Compact mode"
54
+ * <local-command-caveat>...</local-command-caveat> → strip entirely
55
+ * <antml:thinking>...</antml:thinking> → strip entirely
56
+ * <antml:tool_use>...</antml:tool_use> → strip entirely
57
+ */
58
+ function stripXmlTags(text) {
59
+ // 1. Extract useful command stdout
60
+ let result = text.replace(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/gi, (_, content) => content.trim());
61
+ // 2. Extract command name and produce clean status
62
+ const cmdMatch = result.match(/<command-name>(.*?)<\/command-name>/i);
63
+ if (cmdMatch) {
64
+ const cmd = cmdMatch[1].trim();
65
+ const cmdMap = {
66
+ '/plan': '📋 Plan mode enabled',
67
+ '/compact': '🔄 Compact mode',
68
+ '/bug': '🐛 Bug mode',
69
+ '/review': '🔍 Review mode',
70
+ };
71
+ const clean = cmdMap[cmd] || `⚡ ${cmd}`;
72
+ // Replace the command block with clean status
73
+ result = result.replace(/<command-name>[\s\S]*?<\/command-name>/gi, clean);
74
+ result = result.replace(/<command-args>[\s\S]*?<\/command-args>/gi, '');
75
+ }
76
+ // 3. Strip all remaining CC internal tags (caveat, thinking, tool_use, etc.)
77
+ result = result.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi, '');
78
+ result = result.replace(/<local-command-[a-z]+>[\s\S]*?<\/local-command-[a-z]+>/gi, '');
79
+ result = result.replace(/<antml:[a-z]+>[\s\S]*?<\/antml:[a-z]+>/gi, '');
80
+ result = result.replace(/<antml:[a-z]+\/>/gi, '');
81
+ // 4. Strip remaining known CC internal tags (self-closing or unmatched)
82
+ // Only strip tags from known CC namespaces, not arbitrary angle-bracket content
83
+ result = result.replace(/<\/?(?:local-command-[a-z]+|antml:[a-z]+)(?:\s[^>]*)?\/?>/gi, '');
84
+ // 5. Clean up whitespace left behind
85
+ result = result.replace(/\n{3,}/g, '\n\n').trim();
86
+ return result;
87
+ }
88
+ /**
89
+ * Parse numbered or labeled options from CC permission/question text.
90
+ *
91
+ * CC formats:
92
+ * "1. Yes\n2. Yes, and allow...\n3. No"
93
+ * "y/n" or "(y/n)"
94
+ * "Yes / No"
95
+ *
96
+ * Returns array of {label, value} or null if no options detected.
97
+ * value is what gets sent to CC (the number or the text).
98
+ */
99
+ function parseOptions(text) {
100
+ // Pattern 1: Numbered options "1. Yes\n2. Something else\n3. No"
101
+ const numberedRegex = /^\s*(\d+)\.\s+(.+)$/gm;
102
+ const numbered = [];
103
+ let m;
104
+ while ((m = numberedRegex.exec(text)) !== null) {
105
+ const num = m[1];
106
+ let label = m[2].trim();
107
+ // Truncate long labels for button display (max 30 chars)
108
+ if (label.length > 30)
109
+ label = label.slice(0, 28) + '…';
110
+ numbered.push({ label: `${num}. ${label}`, value: num });
111
+ }
112
+ if (numbered.length >= 2)
113
+ return numbered.slice(0, 4); // Max 4 buttons
114
+ // Pattern 2: y/n shorthand
115
+ if (/\(?\s*[yY]\s*\/\s*[nN]\s*\)?/.test(text)) {
116
+ return [
117
+ { label: '✅ Yes', value: 'y' },
118
+ { label: '❌ No', value: 'n' },
119
+ ];
120
+ }
121
+ // Pattern 3: Yes / No explicit
122
+ if (/\b[Yy]es\b.*\b[Nn]o\b/.test(text)) {
123
+ return [
124
+ { label: '✅ Yes', value: 'yes' },
125
+ { label: '❌ No', value: 'no' },
126
+ ];
127
+ }
128
+ // Pattern 4: Allow/Deny
129
+ if (/\b[Aa]llow\b/.test(text) || /\b[Dd]eny\b/.test(text)) {
130
+ return [
131
+ { label: '✅ Allow', value: 'allow' },
132
+ { label: '❌ Deny', value: 'deny' },
133
+ ];
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Detect and format CC sub-agent/explore tree output.
139
+ * Pattern: "● N agents finished\n ├─ name · stats\n └─ name · stats"
140
+ * Returns formatted string or null if no tree detected.
141
+ */
142
+ function formatSubAgentTree(text) {
143
+ // Match the tree header
144
+ const headerMatch = text.match(/●\s+(\d+)\s+(explore|sub-?agent|agent)s?\s+(finished|running|launched)/i);
145
+ if (!headerMatch)
146
+ return null;
147
+ const count = parseInt(headerMatch[1]);
148
+ const status = headerMatch[3].toLowerCase();
149
+ // Extract agent entries
150
+ const entries = [];
151
+ const entryRegex = /[├└│─\s●]*\s*(.+?)\s*[·•]\s*(\d+)\s*(tool uses?|steps?)\s*[·•]\s*([\d.]+[kKmM]?)\s*tokens?/gi;
152
+ let m;
153
+ while ((m = entryRegex.exec(text)) !== null) {
154
+ const name = m[1].trim();
155
+ const tools = m[2];
156
+ const tokens = m[4];
157
+ entries.push(`${bold(name)} ${tools} tools ${tokens} tokens`);
158
+ }
159
+ // Also try simpler format without token count
160
+ if (entries.length === 0) {
161
+ const simpleRegex = /[├└│─\s●]*\s*(.+?)\s*[·•]\s*(\d+)\s*(tool uses?|steps?)/gi;
162
+ while ((m = simpleRegex.exec(text)) !== null) {
163
+ entries.push(`${bold(m[1].trim())} ${m[2]} tools`);
164
+ }
165
+ }
166
+ if (entries.length === 0)
167
+ return null;
168
+ const emoji = status === 'running' ? '🔄' : status === 'finished' ? '✅' : '🚀';
169
+ const header = `${emoji} ${bold(String(count))} ${status}`;
170
+ const body = entries.slice(0, 5).join('\n');
171
+ const extra = entries.length > 5 ? `\n +${entries.length - 5} more` : '';
172
+ return `${header}\n${body}${extra}`;
173
+ }
174
+ /**
175
+ * Convert Markdown to Telegram HTML.
176
+ * Handles: **bold**, `code`, ```blocks```, [links](url), tables
177
+ * Must be called BEFORE wrapping in blockquote/pre tags.
178
+ */
179
+ function md2html(md) {
180
+ let result = '';
181
+ const lines = md.split('\n');
182
+ let inCodeBlock = false;
183
+ let inTable = false;
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const line = lines[i];
186
+ // Code block toggle
187
+ if (line.trimStart().startsWith('```')) {
188
+ if (inCodeBlock) {
189
+ result += '</pre>\n';
190
+ inCodeBlock = false;
191
+ }
192
+ else {
193
+ // Close any open table before code block
194
+ if (inTable) {
195
+ result += '\n';
196
+ inTable = false;
197
+ }
198
+ inCodeBlock = true;
199
+ result += '<pre>';
200
+ }
201
+ continue;
202
+ }
203
+ if (inCodeBlock) {
204
+ result += esc(line) + '\n';
205
+ continue;
206
+ }
207
+ // Markdown table detection: lines starting with |
208
+ if (line.trimStart().startsWith('|') && line.trimEnd().endsWith('|')) {
209
+ // Skip separator row (|---|---|)
210
+ if (/^\|[\s\-:|]+\|$/.test(line.trim()))
211
+ continue;
212
+ if (!inTable) {
213
+ // Close any previous content
214
+ result += '\n';
215
+ inTable = true;
216
+ }
217
+ // Parse table row
218
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
219
+ if (cells.length === 0)
220
+ continue;
221
+ // First row becomes header (bold)
222
+ const isFirstRow = !result.includes('•') || i === 0 || !lines[i - 1]?.trimStart().startsWith('|');
223
+ const formatted = cells.map(c => isFirstRow ? bold(c) : esc(c)).join(' — ');
224
+ result += `• ${formatted}\n`;
225
+ continue;
226
+ }
227
+ else if (inTable) {
228
+ // End of table
229
+ inTable = false;
230
+ }
231
+ let processed = esc(line);
232
+ // Headers → bold
233
+ processed = processed.replace(/^#{1,4}\s+(.+)$/, '<b>$1</b>');
234
+ // Bold: **text** or __text__
235
+ processed = processed.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
236
+ processed = processed.replace(/__(.+?)__/g, '<b>$1</b>');
237
+ // Inline code: `text` (before italic to avoid conflicts)
238
+ processed = processed.replace(/`([^`]+?)`/g, '<code>$1</code>');
239
+ // Italic: *text* or _text_ (not inside words)
240
+ processed = processed.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<i>$1</i>');
241
+ processed = processed.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<i>$1</i>');
242
+ // Links: [text](url)
243
+ processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
244
+ // List bullets
245
+ processed = processed.replace(/^(\s*)[-*]\s+/, '$1• ');
246
+ result += processed + '\n';
247
+ }
248
+ // Close unclosed blocks
249
+ if (inCodeBlock)
250
+ result += '</pre>\n';
251
+ if (inTable)
252
+ result += '\n';
253
+ return result.trimEnd();
254
+ }
255
+ // ── Message Formatting ──────────────────────────────────────────────────────
256
+ function formatSessionCreated(name, workDir, id, meta) {
257
+ const shortId = id.slice(0, 8);
258
+ const shortDir = shortenHomePath(workDir);
259
+ const parts = [`${bold(name)} ${code(shortDir)} ${code(shortId)}`];
260
+ const flags = [];
261
+ if (meta?.permissionMode && meta.permissionMode !== 'default')
262
+ flags.push(String(meta.permissionMode));
263
+ else if (meta?.autoApprove)
264
+ flags.push('auto-approve');
265
+ if (meta?.model)
266
+ flags.push(String(meta.model));
267
+ if (flags.length)
268
+ parts.push(flags.join(' · '));
269
+ if (meta?.prompt) {
270
+ const prompt = String(meta.prompt);
271
+ if (prompt.length > 150) {
272
+ // Long prompt → expandable blockquote
273
+ parts.push(`<blockquote expandable>${esc(prompt)}</blockquote>`);
274
+ }
275
+ else {
276
+ parts.push(italic(esc(prompt)));
277
+ }
278
+ }
279
+ return `🚀 ${parts.join('\n')}`;
280
+ }
281
+ function formatSessionEnded(name, detail, progress) {
282
+ const duration = elapsed(Date.now() - progress.startedAt);
283
+ const lines = [`✅ ${bold('Done')} ${duration} · ${progress.totalMessages} msgs`];
284
+ // Quality gate checklist
285
+ const checks = [];
286
+ if (progress.errors === 0)
287
+ checks.push('☑ No errors');
288
+ else
289
+ checks.push(`☒ ${progress.errors} errors`);
290
+ if (progress.edits || progress.creates) {
291
+ const edited = progress.filesEdited.slice(0, 5).map(f => code(shortPath(f))).join(', ');
292
+ const extra = progress.filesEdited.length > 5 ? ` +${progress.filesEdited.length - 5}` : '';
293
+ checks.push(`☑ Files: ${edited}${extra}`);
294
+ }
295
+ if (checks.length)
296
+ lines.push(checks.join('\n'));
297
+ if (detail) {
298
+ const d = truncate(detail, 200);
299
+ lines.push(esc(d));
300
+ }
301
+ return lines.join('\n\n');
302
+ }
303
+ function formatAssistantMessage(detail) {
304
+ let text = detail.trim();
305
+ if (!text)
306
+ return null;
307
+ // P0: Strip CC internal XML tags FIRST
308
+ text = stripXmlTags(text);
309
+ text = text.trim();
310
+ if (!text)
311
+ return null;
312
+ // P2: Detect sub-agent/explore tree and format it
313
+ const treeResult = formatSubAgentTree(text);
314
+ if (treeResult)
315
+ return treeResult;
316
+ // Strip filler lines
317
+ const allLines = text.split('\n');
318
+ const lines = allLines.filter(l => {
319
+ const t = l.trim();
320
+ return t && !t.match(/^(Let me|I'll|Sure,|Okay,|Alright,|Great,|Now I|Now let me|I'm going to|First,? I|Looking at|I need to|I want to|I should|Next,? I)/i);
321
+ });
322
+ if (lines.length === 0)
323
+ return null;
324
+ const firstLine = lines[0];
325
+ // Short helper: first 2 lines max 200 chars (Quick Update format)
326
+ const short = () => md2html(truncate(lines.slice(0, 2).join(' '), 200));
327
+ // Long helper: first line as summary, rest in expandable blockquote
328
+ // Uses md2html to convert markdown → HTML BEFORE wrapping in blockquote
329
+ const withExpandable = (emoji, maxSummary = 200) => {
330
+ const summary = md2html(truncate(firstLine, maxSummary));
331
+ if (lines.length <= 2)
332
+ return `${emoji} ${summary}`;
333
+ const rest = lines.slice(1).join('\n');
334
+ const restTruncated = truncate(rest, 1500);
335
+ const restHtml = md2html(restTruncated);
336
+ return `${emoji} ${summary}\n<blockquote expandable>${restHtml}</blockquote>`;
337
+ };
338
+ // P3: Plan detection — show full plan in expandable
339
+ if (/^(plan|steps?|approach|here's (how|what|the plan)|my approach)/im.test(firstLine)) {
340
+ return withExpandable('📋');
341
+ }
342
+ // Question — send immediately (important)
343
+ if (/\?$/.test(firstLine) || /^(what|how|why|when|where|should|can you|do you|is there)/im.test(firstLine)) {
344
+ return withExpandable('❓');
345
+ }
346
+ // Summary/conclusion
347
+ if (/^(summary|done|complete|finished|all (tests|checks)|build (pass|succeed)|here (is|are) the)/im.test(firstLine)) {
348
+ return withExpandable('✅');
349
+ }
350
+ // Sub-agent launch (but no tree yet)
351
+ if (/launching\s+\d+\s+(explore|sub-?agent|agent)/im.test(firstLine)) {
352
+ return withExpandable('🚀');
353
+ }
354
+ // Code changes
355
+ if (/^(writing|implementing|adding|creating|updating|fixing|refactor)/im.test(firstLine)) {
356
+ return `✏️ ${short()}`;
357
+ }
358
+ // Analysis
359
+ if (/^(reading|examining|looking at|checking|analyzing|inspecting|reviewing)/im.test(firstLine)) {
360
+ return `🔍 ${short()}`;
361
+ }
362
+ // Default: short update
363
+ if (lines.length <= 2)
364
+ return `💬 ${short()}`;
365
+ return withExpandable('💬');
366
+ }
367
+ function parseToolUse(detail) {
368
+ const d = detail.trim();
369
+ // Read file
370
+ const readMatch = d.match(/^Read[:\s]+(.+)/im);
371
+ if (readMatch)
372
+ return { icon: '📖', label: `Reading ${shortPath(readMatch[1].trim())}`, file: readMatch[1].trim(), category: 'read' };
373
+ // Edit file
374
+ const editMatch = d.match(/^Edit[:\s]+(.+)/im);
375
+ if (editMatch)
376
+ return { icon: '✏️', label: `Editing ${shortPath(editMatch[1].trim())}`, file: editMatch[1].trim(), category: 'edit' };
377
+ // Write/Create file
378
+ const writeMatch = d.match(/^Write[:\s]+(.+)/im);
379
+ if (writeMatch)
380
+ return { icon: '📝', label: `Creating ${shortPath(writeMatch[1].trim())}`, file: writeMatch[1].trim(), category: 'create' };
381
+ // Search/Grep/Glob
382
+ const searchMatch = d.match(/^(Search|Grep|Glob)[:\s]+["']?(.+?)["']?$/im);
383
+ if (searchMatch)
384
+ return { icon: '🔍', label: `Searching: ${truncate(searchMatch[2], 50)}`, category: 'search' };
385
+ // Bash/Run
386
+ const bashMatch = d.match(/^(Bash|Run)[:\s]+(.+)$/im);
387
+ if (bashMatch) {
388
+ const cmd = bashMatch[2].trim();
389
+ return { icon: '💻', label: truncate(cmd, 70), cmd, category: 'command' };
390
+ }
391
+ // List
392
+ const listMatch = d.match(/^List(ing|Dir)?[:\s]*(.+)$/im);
393
+ if (listMatch)
394
+ return { icon: '📂', label: `Listing ${shortPath(listMatch[2].trim())}`, category: 'read' };
395
+ // Generic — only if we can extract a meaningful name (>2 chars, not just punctuation)
396
+ const toolName = d.split(/[:(\s]/)[0]?.trim() || '';
397
+ if (toolName.length < 2 || /^[^a-zA-Z]+$/.test(toolName)) {
398
+ // Unrecognized tool — track silently, don't show to user
399
+ return { icon: '', label: '', category: 'other' };
400
+ }
401
+ return { icon: '🔧', label: toolName, category: 'other' };
402
+ }
403
+ function formatToolResult(detail) {
404
+ // Success → silent
405
+ if (/^(success|ok|done|completed|passed)$/im.test(detail.trim()))
406
+ return null;
407
+ // Build output — extract file:line + TS error code + short message
408
+ if (/build|compil|tsc/i.test(detail)) {
409
+ if (/error|failed/i.test(detail)) {
410
+ const tsErrors = detail.split('\n')
411
+ .filter(l => /TS\d{4,}/.test(l))
412
+ .map(l => {
413
+ // Extract: src/file.ts(line,col): error TS2345: message...
414
+ const m = l.match(/([^\s/]*\/[^\s(]+)\((\d+),?\d*\):\s*error\s+(TS\d+):\s*(.+)/);
415
+ if (m)
416
+ return `${shortPath(m[1])}:${m[2]} — ${m[3]}: ${truncate(m[4], 80)}`;
417
+ // Fallback: just truncate the line
418
+ return truncate(l.trim(), 120);
419
+ })
420
+ .slice(0, 4);
421
+ const errorBlock = tsErrors.length > 0
422
+ ? `\n<blockquote expandable><pre>${esc(tsErrors.join('\n'))}</pre></blockquote>`
423
+ : '';
424
+ return { text: `❌ ${bold('Build failed')}${errorBlock}`, isError: true };
425
+ }
426
+ return { text: `💻 tsc clean`, isError: false };
427
+ }
428
+ // Test output — differentiate single file vs full suite
429
+ if (/test|spec|vitest|jest/i.test(detail)) {
430
+ const passedMatch = detail.match(/(\d+)\s*(passed|passing)/i);
431
+ const failedMatch = detail.match(/(\d+)\s*(failed|failing)/i);
432
+ // Try to extract test file name for single-file runs
433
+ const fileMatch = detail.match(/([a-zA-Z0-9_-]+\.test\.[tj]s)/i);
434
+ // Detect "Test Files N passed" for full suite
435
+ const suiteMatch = detail.match(/Test Files\s+(\d+)\s*passed/i);
436
+ const prefix = fileMatch && !suiteMatch ? fileMatch[1] : suiteMatch ? 'Full suite' : '';
437
+ if (failedMatch && parseInt(failedMatch[1]) > 0) {
438
+ const label = prefix ? `${prefix}: ` : '';
439
+ return { text: `💻 ${label}${failedMatch[1]} tests failed`, isError: true };
440
+ }
441
+ if (passedMatch) {
442
+ const label = prefix ? `${prefix}: ` : '';
443
+ return { text: `💻 ${label}${passedMatch[1]} tests passed`, isError: false };
444
+ }
445
+ }
446
+ // Lint
447
+ if (/lint|eslint|prettier/i.test(detail)) {
448
+ if (/error|warning|failed/i.test(detail)) {
449
+ const count = detail.match(/(\d+)\s*(error|warning)/i);
450
+ return { text: `💻 lint: ${count ? count[0] : 'issues found'}`, isError: true };
451
+ }
452
+ return null;
453
+ }
454
+ // Error — blockquote expandable for long traces
455
+ if (/error|failed|exception|ENOENT|EACCES|ERR_/i.test(detail)) {
456
+ const errorLines = detail.split('\n').filter(l => l.trim()).slice(0, 8);
457
+ const firstError = esc(truncate(errorLines[0] || detail, 200));
458
+ if (errorLines.length > 1) {
459
+ const rest = errorLines.slice(1).map(l => esc(l)).join('\n');
460
+ return {
461
+ text: `❌ ${firstError}\n<blockquote expandable><pre>${truncate(rest, 1000)}</pre></blockquote>`,
462
+ isError: true,
463
+ };
464
+ }
465
+ return { text: `❌ ${firstError}`, isError: true };
466
+ }
467
+ return null; // Success → silent
468
+ }
469
+ function formatProgressCard(progress) {
470
+ const duration = elapsed(Date.now() - progress.startedAt);
471
+ const counters = [];
472
+ if (progress.reads)
473
+ counters.push(`${progress.reads}r`);
474
+ if (progress.edits)
475
+ counters.push(`${progress.edits}e`);
476
+ if (progress.creates)
477
+ counters.push(`${progress.creates}c`);
478
+ if (progress.commands)
479
+ counters.push(`${progress.commands}cmd`);
480
+ const counterStr = counters.length ? ` ${counters.join(' ')}` : '';
481
+ const parts = [`📊 ${bold(duration)} · ${progress.totalMessages} msgs${counterStr}`];
482
+ if (progress.filesEdited.length > 0) {
483
+ const files = progress.filesEdited.slice(0, 4).map(f => code(shortPath(f))).join(', ');
484
+ const extra = progress.filesEdited.length > 4 ? ` +${progress.filesEdited.length - 4}` : '';
485
+ parts.push(`Files: ${files}${extra}`);
486
+ }
487
+ if (progress.lastMessage) {
488
+ parts.push(esc(truncate(progress.lastMessage, 150)));
489
+ }
490
+ return parts.join('\n');
491
+ }
492
+ // ── Telegram Channel ────────────────────────────────────────────────────────
493
+ export class TelegramChannel {
494
+ config;
495
+ name = 'telegram';
496
+ static DEFAULT_TOPIC_TTL_MS = 24 * 60 * 60 * 1000;
497
+ static TOPIC_CLEANUP_RETRY_MS = 60_000;
498
+ topics = new Map();
499
+ progress = new Map();
500
+ pollOffset = 0;
501
+ polling = false;
502
+ rateLimitUntil = 0;
503
+ pollBackoffMs = 1_000; // Exponential backoff for poll errors
504
+ onInbound = null;
505
+ topicCleanupTimers = new Map();
506
+ topicCleanupSweepTimer = null;
507
+ topicTtlMs;
508
+ // Rate limiting & batching
509
+ messageQueue = new Map();
510
+ lastSent = new Map();
511
+ flushTimers = new Map();
512
+ pendingTool = new Map();
513
+ // Issue #89 L12: Backpressure — max in-flight messages per session
514
+ static MAX_IN_FLIGHT = 10;
515
+ inFlightCount = new Map();
516
+ // Issue #46: Buffer messages that arrive before topic is created
517
+ preTopicBuffer = new Map();
518
+ // Group consecutive reads
519
+ pendingReads = new Map();
520
+ readTimer = new Map();
521
+ // Dedup: track last user message text per session to avoid duplicates
522
+ lastUserMessage = new Map();
523
+ // Issue #71: Swarm monitor for /swarm command
524
+ swarmMonitor = null;
525
+ /** Set the swarm monitor for /swarm command support. */
526
+ setSwarmMonitor(monitor) {
527
+ this.swarmMonitor = monitor;
528
+ }
529
+ constructor(config) {
530
+ this.config = config;
531
+ const configuredTtlMs = config.topicTtlMs ?? TelegramChannel.DEFAULT_TOPIC_TTL_MS;
532
+ this.topicTtlMs = Number.isFinite(configuredTtlMs)
533
+ ? Math.max(0, configuredTtlMs)
534
+ : TelegramChannel.DEFAULT_TOPIC_TTL_MS;
535
+ }
536
+ /** Call Telegram Bot API with retry on 429. Instance method so it can access rateLimitUntil. */
537
+ async tgApi(method, body, retries = 3) {
538
+ for (let attempt = 0; attempt <= retries; attempt++) {
539
+ const now = Date.now();
540
+ if (this.rateLimitUntil > now) {
541
+ const waitMs = this.rateLimitUntil - now;
542
+ console.log(`Telegram rate limit: waiting ${Math.ceil(waitMs / 1000)}s before ${method}`);
543
+ await sleep(waitMs);
544
+ }
545
+ const res = await fetch(`https://api.telegram.org/bot${this.config.botToken}/${method}`, {
546
+ method: 'POST',
547
+ headers: { 'Content-Type': 'application/json' },
548
+ body: JSON.stringify(body),
549
+ });
550
+ const data = (await res.json());
551
+ if (data.ok)
552
+ return data.result;
553
+ if (res.status === 429 && data.parameters?.retry_after) {
554
+ const retryAfter = data.parameters.retry_after;
555
+ this.rateLimitUntil = Date.now() + retryAfter * 1000 + 500;
556
+ console.log(`Telegram 429: retry_after=${retryAfter}s, attempt ${attempt + 1}/${retries + 1}`);
557
+ if (attempt < retries) {
558
+ await sleep(retryAfter * 1000 + 500);
559
+ continue;
560
+ }
561
+ }
562
+ if (attempt === retries) {
563
+ throw new Error(`Telegram API ${method}: ${data.description || 'unknown error'}`);
564
+ }
565
+ await sleep(1000 * (attempt + 1));
566
+ }
567
+ throw new Error('Unreachable');
568
+ }
569
+ pollLoopPromise = Promise.resolve();
570
+ async init(onInbound) {
571
+ this.onInbound = onInbound;
572
+ this.polling = true;
573
+ this.startTopicCleanupSweep();
574
+ this.pollLoopPromise = this.pollLoop(); // store promise for graceful shutdown
575
+ console.log(`Telegram channel: polling started, group ${this.config.groupChatId}`);
576
+ }
577
+ async destroy() {
578
+ this.polling = false;
579
+ // Await poll loop exit (it waits for in-flight getUpdates, up to 10s + buffer)
580
+ const timeoutMs = 12_000;
581
+ await Promise.race([
582
+ this.pollLoopPromise,
583
+ new Promise(resolve => setTimeout(resolve, timeoutMs)),
584
+ ]);
585
+ for (const timer of this.flushTimers.values())
586
+ clearTimeout(timer);
587
+ for (const timer of this.readTimer.values())
588
+ clearTimeout(timer);
589
+ for (const timer of this.topicCleanupTimers.values())
590
+ clearTimeout(timer);
591
+ if (this.topicCleanupSweepTimer) {
592
+ clearInterval(this.topicCleanupSweepTimer);
593
+ this.topicCleanupSweepTimer = null;
594
+ }
595
+ this.flushTimers.clear();
596
+ this.readTimer.clear();
597
+ this.topicCleanupTimers.clear();
598
+ }
599
+ async onSessionCreated(payload) {
600
+ const topicName = `🤖 ${payload.session.name}`;
601
+ const result = (await this.tgApi('createForumTopic', {
602
+ chat_id: this.config.groupChatId,
603
+ name: topicName,
604
+ }));
605
+ const topicId = result.message_thread_id;
606
+ this.clearTopicCleanupTimer(payload.session.id);
607
+ this.topics.set(payload.session.id, {
608
+ sessionId: payload.session.id,
609
+ topicId,
610
+ windowName: payload.session.name,
611
+ endedAt: null,
612
+ cleanupScheduledAt: null,
613
+ deleting: false,
614
+ });
615
+ this.progress.set(payload.session.id, {
616
+ totalMessages: 0,
617
+ reads: 0,
618
+ edits: 0,
619
+ creates: 0,
620
+ commands: 0,
621
+ searches: 0,
622
+ errors: 0,
623
+ filesRead: [],
624
+ filesEdited: [],
625
+ startedAt: Date.now(),
626
+ lastMessage: '',
627
+ currentStatus: 'starting',
628
+ progressMessageId: null,
629
+ });
630
+ await this.sendImmediate(payload.session.id, formatSessionCreated(payload.session.name, payload.session.workDir, payload.session.id, payload.meta));
631
+ // Issue #46: Replay any messages that arrived before topic was created
632
+ const buffered = this.preTopicBuffer.get(payload.session.id);
633
+ if (buffered && buffered.length > 0) {
634
+ console.log(`Telegram: replaying ${buffered.length} buffered messages for ${payload.session.name}`);
635
+ for (const item of buffered) {
636
+ if (item.method === 'message') {
637
+ await this.onMessage(item.payload);
638
+ }
639
+ else if (item.method === 'statusChange') {
640
+ await this.onStatusChange(item.payload);
641
+ }
642
+ }
643
+ }
644
+ this.preTopicBuffer.delete(payload.session.id);
645
+ }
646
+ async onSessionEnded(payload) {
647
+ // Flush pending reads
648
+ await this.flushReads(payload.session.id);
649
+ await this.flushQueue(payload.session.id);
650
+ const prog = this.progress.get(payload.session.id);
651
+ if (prog) {
652
+ // Use taskComplete style for clean session endings
653
+ const duration = elapsed(Date.now() - prog.startedAt);
654
+ const checks = [];
655
+ checks.push([`${prog.totalMessages} msgs`, true]);
656
+ checks.push([prog.errors === 0 ? 'No errors' : `${prog.errors} errors`, prog.errors === 0]);
657
+ if (prog.filesEdited.length > 0) {
658
+ checks.push([`${prog.filesEdited.length} files edited`, true]);
659
+ }
660
+ const styled = taskComplete({
661
+ taskRef: payload.session.name,
662
+ title: truncate(payload.detail || 'Session complete', 80),
663
+ duration,
664
+ branch: '',
665
+ checks,
666
+ });
667
+ await this.sendStyled(payload.session.id, styled);
668
+ }
669
+ else {
670
+ const styled = quickUpdate('✅', `${payload.session.name} — Session ended`);
671
+ await this.sendStyled(payload.session.id, styled);
672
+ }
673
+ this.scheduleTopicCleanup(payload.session.id);
674
+ this.cleanupSessionRuntimeState(payload.session.id);
675
+ }
676
+ async onMessage(payload) {
677
+ // Issue #46: If topic doesn't exist yet, buffer the message
678
+ if (!this.topics.has(payload.session.id)) {
679
+ if (!this.preTopicBuffer.has(payload.session.id)) {
680
+ this.preTopicBuffer.set(payload.session.id, []);
681
+ }
682
+ this.preTopicBuffer.get(payload.session.id).push({ method: 'message', payload });
683
+ return;
684
+ }
685
+ const progress = this.progress.get(payload.session.id);
686
+ if (progress)
687
+ progress.totalMessages++;
688
+ switch (payload.event) {
689
+ case 'message.user': {
690
+ await this.flushReads(payload.session.id);
691
+ // Dedup: skip if this is the same message we just sent
692
+ const lastMsg = this.lastUserMessage.get(payload.session.id);
693
+ if (lastMsg === payload.detail)
694
+ break;
695
+ this.lastUserMessage.set(payload.session.id, payload.detail);
696
+ await this.queueMessage(payload.session.id, `👤 ${bold('User')} ${esc(truncate(payload.detail, 200))}`, 'high');
697
+ break;
698
+ }
699
+ case 'message.assistant': {
700
+ if (progress)
701
+ progress.lastMessage = truncate(payload.detail, 500);
702
+ const formatted = formatAssistantMessage(payload.detail);
703
+ if (formatted) {
704
+ await this.queueMessage(payload.session.id, formatted, 'normal');
705
+ }
706
+ break;
707
+ }
708
+ case 'message.thinking':
709
+ // Completely silent — no thinking noise
710
+ break;
711
+ case 'message.tool_use': {
712
+ const detail = payload.detail?.trim();
713
+ // Skip empty/whitespace-only tool_use — nothing useful to show
714
+ if (!detail)
715
+ break;
716
+ const tool = parseToolUse(detail);
717
+ this.pendingTool.set(payload.session.id, tool);
718
+ if (progress) {
719
+ switch (tool.category) {
720
+ case 'read':
721
+ progress.reads++;
722
+ if (tool.file)
723
+ progress.filesRead.push(tool.file);
724
+ break;
725
+ case 'edit':
726
+ progress.edits++;
727
+ if (tool.file && !progress.filesEdited.includes(tool.file))
728
+ progress.filesEdited.push(tool.file);
729
+ break;
730
+ case 'create':
731
+ progress.creates++;
732
+ if (tool.file && !progress.filesEdited.includes(tool.file))
733
+ progress.filesEdited.push(tool.file);
734
+ break;
735
+ case 'search':
736
+ progress.searches++;
737
+ break;
738
+ case 'command':
739
+ progress.commands++;
740
+ break;
741
+ }
742
+ }
743
+ break;
744
+ }
745
+ case 'message.tool_result': {
746
+ const tool = this.pendingTool.get(payload.session.id);
747
+ this.pendingTool.delete(payload.session.id);
748
+ const result = formatToolResult(payload.detail);
749
+ if (result) {
750
+ // Has result to show (error, build, test)
751
+ await this.flushReads(payload.session.id);
752
+ if (result.isError && progress)
753
+ progress.errors++;
754
+ // Use alert style for critical errors, quick update for non-critical
755
+ if (result.isError && tool?.category === 'command') {
756
+ const styled = styleAlert({ title: tool.label || 'Command failed', resourceId: payload.session.name, details: truncate(payload.detail, 200) });
757
+ await this.sendStyled(payload.session.id, styled);
758
+ }
759
+ else {
760
+ await this.queueMessage(payload.session.id, result.text, result.isError ? 'high' : 'normal');
761
+ }
762
+ }
763
+ else if (tool && tool.label) {
764
+ // Success → show the tool action grouped with consecutive reads
765
+ if (tool.category === 'read' && tool.file) {
766
+ this.addPendingRead(payload.session.id, tool.file);
767
+ }
768
+ else {
769
+ await this.flushReads(payload.session.id);
770
+ await this.queueMessage(payload.session.id, `${tool.icon} ${esc(tool.label)}`, 'low');
771
+ }
772
+ }
773
+ // else: unknown/empty tool — silent (tracked in progress counters only)
774
+ break;
775
+ }
776
+ }
777
+ // Progress card every 5 messages — edit-in-place (was 15, too infrequent)
778
+ if (progress && progress.totalMessages > 0 && progress.totalMessages % 5 === 0) {
779
+ await this.flushReads(payload.session.id);
780
+ const progressText = formatProgressCard(progress);
781
+ if (progress.progressMessageId) {
782
+ // Edit existing progress message
783
+ await this.editMessage(payload.session.id, progress.progressMessageId, progressText);
784
+ }
785
+ else {
786
+ // First progress message — send new, store ID
787
+ const msgId = await this.sendImmediate(payload.session.id, progressText);
788
+ if (msgId)
789
+ progress.progressMessageId = msgId;
790
+ }
791
+ }
792
+ }
793
+ async onStatusChange(payload) {
794
+ // Issue #46: If topic doesn't exist yet, buffer the status change
795
+ if (!this.topics.has(payload.session.id)) {
796
+ if (!this.preTopicBuffer.has(payload.session.id)) {
797
+ this.preTopicBuffer.set(payload.session.id, []);
798
+ }
799
+ this.preTopicBuffer.get(payload.session.id).push({ method: 'statusChange', payload });
800
+ return;
801
+ }
802
+ // Track current status for progress cards
803
+ const progress = this.progress.get(payload.session.id);
804
+ const statusName = payload.event.replace('status.', '');
805
+ if (progress)
806
+ progress.currentStatus = statusName;
807
+ switch (payload.event) {
808
+ case 'status.permission': {
809
+ await this.flushReads(payload.session.id);
810
+ await this.flushQueue(payload.session.id);
811
+ const permSummary = truncate(payload.detail, 300);
812
+ const options = parseOptions(payload.detail);
813
+ const buttons = [];
814
+ if (options) {
815
+ // Dynamic buttons from CC's options
816
+ for (const opt of options) {
817
+ buttons.push({
818
+ text: opt.label,
819
+ callback_data: `cb_option:${payload.session.id}:${opt.value}`,
820
+ });
821
+ }
822
+ }
823
+ else {
824
+ // Fallback: generic approve/reject
825
+ buttons.push({ text: '✅ Approve', callback_data: `perm_approve:${payload.session.id}` }, { text: '❌ Reject', callback_data: `perm_reject:${payload.session.id}` });
826
+ }
827
+ const permStyled = {
828
+ text: `⚠️ Permission: ${esc(permSummary)}`,
829
+ parse_mode: 'HTML',
830
+ reply_markup: { inline_keyboard: [buttons] },
831
+ };
832
+ await this.sendStyled(payload.session.id, permStyled);
833
+ break;
834
+ }
835
+ case 'status.idle':
836
+ // Silent unless there's meaningful detail
837
+ break;
838
+ case 'status.working':
839
+ // Silent
840
+ break;
841
+ case 'status.question': {
842
+ await this.flushReads(payload.session.id);
843
+ await this.flushQueue(payload.session.id);
844
+ const questionText = esc(truncate(payload.detail, 400));
845
+ const options = parseOptions(payload.detail);
846
+ const buttons = [];
847
+ if (options) {
848
+ for (const opt of options) {
849
+ buttons.push({
850
+ text: opt.label,
851
+ callback_data: `cb_option:${payload.session.id}:${opt.value}`,
852
+ });
853
+ }
854
+ // Always add Skip for questions
855
+ if (buttons.length < 4) {
856
+ buttons.push({ text: '🤷 Skip', callback_data: `cb_skip:${payload.session.id}` });
857
+ }
858
+ }
859
+ else {
860
+ buttons.push({ text: '✅ Yes', callback_data: `cb_yes:${payload.session.id}` }, { text: '❌ No', callback_data: `cb_no:${payload.session.id}` }, { text: '🤷 Skip', callback_data: `cb_skip:${payload.session.id}` });
861
+ }
862
+ const qStyled = {
863
+ text: `❓ ${questionText}`,
864
+ parse_mode: 'HTML',
865
+ reply_markup: { inline_keyboard: [buttons] },
866
+ };
867
+ await this.sendStyled(payload.session.id, qStyled);
868
+ break;
869
+ }
870
+ case 'status.plan': {
871
+ await this.flushReads(payload.session.id);
872
+ await this.flushQueue(payload.session.id);
873
+ const planLines = payload.detail.split('\n');
874
+ const planSummary = md2html(truncate(planLines[0] || payload.detail, 200));
875
+ const planBody = planLines.length > 1
876
+ ? `\n<blockquote expandable>${md2html(planLines.slice(1).join('\n'))}</blockquote>`
877
+ : '';
878
+ const planStyled = {
879
+ text: `📋 ${planSummary}${planBody}`,
880
+ parse_mode: 'HTML',
881
+ reply_markup: {
882
+ inline_keyboard: [
883
+ [
884
+ { text: '▶ Execute', callback_data: `plan_exec:${payload.session.id}` },
885
+ { text: '⚡ Execute All', callback_data: `plan_exec_all:${payload.session.id}` },
886
+ { text: '❌ Cancel', callback_data: `plan_cancel:${payload.session.id}` },
887
+ ],
888
+ ],
889
+ },
890
+ };
891
+ await this.sendStyled(payload.session.id, planStyled);
892
+ break;
893
+ }
894
+ case 'swarm.teammate_spawned': {
895
+ await this.flushReads(payload.session.id);
896
+ const teammateName = payload.meta?.teammateName || 'unknown';
897
+ const teammateId = payload.meta?.teammateWindowId || '';
898
+ const label = `${bold(teammateName)}${teammateId ? ` ${code(teammateId)}` : ''}`;
899
+ await this.sendImmediate(payload.session.id, `🔧 Teammate ${label} spawned`);
900
+ break;
901
+ }
902
+ case 'swarm.teammate_finished': {
903
+ await this.flushReads(payload.session.id);
904
+ const teammateName = payload.meta?.teammateName || 'unknown';
905
+ await this.sendImmediate(payload.session.id, `✅ Teammate ${bold(teammateName)} finished`);
906
+ break;
907
+ }
908
+ }
909
+ }
910
+ // ── Read grouping ─────────────────────────────────────────────────────────
911
+ addPendingRead(sessionId, file) {
912
+ if (!this.pendingReads.has(sessionId)) {
913
+ this.pendingReads.set(sessionId, []);
914
+ }
915
+ this.pendingReads.get(sessionId).push(file);
916
+ // Flush after 4 seconds to batch consecutive reads
917
+ const existing = this.readTimer.get(sessionId);
918
+ if (existing)
919
+ clearTimeout(existing);
920
+ const timer = setTimeout(() => this.flushReads(sessionId), 4000);
921
+ this.readTimer.set(sessionId, timer);
922
+ }
923
+ async flushReads(sessionId) {
924
+ const timer = this.readTimer.get(sessionId);
925
+ if (timer) {
926
+ clearTimeout(timer);
927
+ this.readTimer.delete(sessionId);
928
+ }
929
+ const files = this.pendingReads.get(sessionId);
930
+ if (!files || files.length === 0)
931
+ return;
932
+ this.pendingReads.delete(sessionId);
933
+ if (files.length === 1) {
934
+ await this.queueMessage(sessionId, `📖 Reading ${code(shortPath(files[0]))}`, 'low');
935
+ }
936
+ else {
937
+ const listed = files.slice(0, 8).map(f => code(shortPath(f))).join(', ');
938
+ const extra = files.length > 8 ? ` +${files.length - 8} more` : '';
939
+ await this.queueMessage(sessionId, `📖 Reading ${bold(String(files.length))} files: ${listed}${extra}`, 'low');
940
+ }
941
+ }
942
+ // ── Styled Message Support ─────────────────────────────────────────────────
943
+ /**
944
+ * Send a StyledMessage (from telegram-style.ts) with inline keyboard support.
945
+ * This is the primary way to send the 6 standard message types.
946
+ */
947
+ async sendStyled(sessionId, styled) {
948
+ await this.flushQueue(sessionId);
949
+ return this.sendStyledToTopic(sessionId, styled);
950
+ }
951
+ async sendStyledToTopic(sessionId, styled) {
952
+ const topic = this.topics.get(sessionId);
953
+ if (!topic)
954
+ return null;
955
+ const truncated = styled.text.length > 4096 ? styled.text.slice(0, 4096) + '\n…' : styled.text;
956
+ // Rate limit: 3s between messages per session
957
+ const lastSentTime = this.lastSent.get(sessionId) || 0;
958
+ const now = Date.now();
959
+ const waitMs = Math.max(0, 3000 - (now - lastSentTime));
960
+ if (waitMs > 0)
961
+ await sleep(waitMs);
962
+ const body = {
963
+ chat_id: this.config.groupChatId,
964
+ message_thread_id: topic.topicId,
965
+ text: truncated,
966
+ parse_mode: 'HTML',
967
+ disable_web_page_preview: true,
968
+ };
969
+ if (styled.reply_markup) {
970
+ body.reply_markup = JSON.stringify(styled.reply_markup);
971
+ }
972
+ try {
973
+ const result = (await this.tgApi('sendMessage', body));
974
+ this.lastSent.set(sessionId, Date.now());
975
+ return result.message_id;
976
+ }
977
+ catch {
978
+ // Fallback: strip HTML + buttons, send plain
979
+ try {
980
+ const plain = truncated.replace(/<[^>]+>/g, '');
981
+ const result = (await this.tgApi('sendMessage', {
982
+ chat_id: this.config.groupChatId,
983
+ message_thread_id: topic.topicId,
984
+ text: plain,
985
+ disable_web_page_preview: true,
986
+ }));
987
+ this.lastSent.set(sessionId, Date.now());
988
+ return result.message_id;
989
+ }
990
+ catch (e) {
991
+ console.error(`Telegram: failed to send styled to topic ${topic.topicId}:`, this.redactError(e));
992
+ return null;
993
+ }
994
+ }
995
+ }
996
+ /**
997
+ * Edit a message in-place with a StyledMessage (for progress updates with buttons).
998
+ */
999
+ async editStyled(sessionId, messageId, styled) {
1000
+ const topic = this.topics.get(sessionId);
1001
+ if (!topic)
1002
+ return false;
1003
+ const truncated = styled.text.length > 4096 ? styled.text.slice(0, 4096) + '\n…' : styled.text;
1004
+ const body = {
1005
+ chat_id: this.config.groupChatId,
1006
+ message_id: messageId,
1007
+ text: truncated,
1008
+ parse_mode: 'HTML',
1009
+ disable_web_page_preview: true,
1010
+ };
1011
+ if (styled.reply_markup) {
1012
+ body.reply_markup = JSON.stringify(styled.reply_markup);
1013
+ }
1014
+ try {
1015
+ await this.tgApi('editMessageText', body);
1016
+ return true;
1017
+ }
1018
+ catch { /* styled edit failed — message deleted or too old */
1019
+ return false;
1020
+ }
1021
+ }
1022
+ // ── Edit in-place (for progress) ────────────────────────────────────────────
1023
+ async editMessage(sessionId, messageId, text) {
1024
+ const topic = this.topics.get(sessionId);
1025
+ if (!topic)
1026
+ return false;
1027
+ const truncated = text.length > 4096 ? text.slice(0, 4096) + '\n…' : text;
1028
+ try {
1029
+ await this.tgApi('editMessageText', {
1030
+ chat_id: this.config.groupChatId,
1031
+ message_id: messageId,
1032
+ text: truncated,
1033
+ parse_mode: 'HTML',
1034
+ disable_web_page_preview: true,
1035
+ });
1036
+ return true;
1037
+ }
1038
+ catch {
1039
+ // Edit can fail if message is too old or unchanged — not critical
1040
+ return false;
1041
+ }
1042
+ }
1043
+ // ── Send / Queue ──────────────────────────────────────────────────────────
1044
+ async sendImmediate(sessionId, text) {
1045
+ await this.flushQueue(sessionId);
1046
+ return this.sendToTopic(sessionId, text);
1047
+ }
1048
+ async sendToTopic(sessionId, text) {
1049
+ const topic = this.topics.get(sessionId);
1050
+ if (!topic)
1051
+ return null;
1052
+ const truncated = text.length > 4096 ? text.slice(0, 4096) + '\n…' : text;
1053
+ // Rate limit: 3s between messages per session
1054
+ const lastSent = this.lastSent.get(sessionId) || 0;
1055
+ const now = Date.now();
1056
+ const waitMs = Math.max(0, 3000 - (now - lastSent));
1057
+ if (waitMs > 0)
1058
+ await sleep(waitMs);
1059
+ // Issue #89 L12: Track in-flight count
1060
+ this.inFlightCount.set(sessionId, (this.inFlightCount.get(sessionId) || 0) + 1);
1061
+ // Try HTML first, fallback to plain text
1062
+ try {
1063
+ const result = (await this.tgApi('sendMessage', {
1064
+ chat_id: this.config.groupChatId,
1065
+ message_thread_id: topic.topicId,
1066
+ text: truncated,
1067
+ parse_mode: 'HTML',
1068
+ disable_web_page_preview: true,
1069
+ }));
1070
+ this.lastSent.set(sessionId, Date.now());
1071
+ this.decrementInFlight(sessionId);
1072
+ return result.message_id;
1073
+ }
1074
+ catch {
1075
+ // Fallback: strip HTML, send plain
1076
+ try {
1077
+ const plain = truncated.replace(/<[^>]+>/g, '');
1078
+ const result = (await this.tgApi('sendMessage', {
1079
+ chat_id: this.config.groupChatId,
1080
+ message_thread_id: topic.topicId,
1081
+ text: plain,
1082
+ disable_web_page_preview: true,
1083
+ }));
1084
+ this.lastSent.set(sessionId, Date.now());
1085
+ this.decrementInFlight(sessionId);
1086
+ return result.message_id;
1087
+ }
1088
+ catch (e) {
1089
+ console.error(`Telegram: failed to send to topic ${topic.topicId}:`, this.redactError(e));
1090
+ this.decrementInFlight(sessionId);
1091
+ return null;
1092
+ }
1093
+ }
1094
+ }
1095
+ /** Issue #89 L12: Decrement in-flight counter, clamped to 0. */
1096
+ decrementInFlight(sessionId) {
1097
+ const current = this.inFlightCount.get(sessionId) || 0;
1098
+ if (current <= 1) {
1099
+ this.inFlightCount.delete(sessionId);
1100
+ }
1101
+ else {
1102
+ this.inFlightCount.set(sessionId, current - 1);
1103
+ }
1104
+ }
1105
+ async queueMessage(sessionId, text, priority) {
1106
+ if (!this.messageQueue.has(sessionId)) {
1107
+ this.messageQueue.set(sessionId, []);
1108
+ }
1109
+ const queue = this.messageQueue.get(sessionId);
1110
+ // Issue #89 L12: Backpressure — if in-flight + pending exceeds max, drop oldest pending
1111
+ const inFlight = this.inFlightCount.get(sessionId) || 0;
1112
+ if (inFlight + queue.length >= TelegramChannel.MAX_IN_FLIGHT) {
1113
+ const dropped = queue.shift();
1114
+ console.warn(`Telegram backpressure: dropped oldest pending message for session ${sessionId} (in-flight: ${inFlight}, queued: ${queue.length})`);
1115
+ void dropped; // consumed
1116
+ }
1117
+ queue.push({ text, priority, timestamp: Date.now() });
1118
+ // High priority: flush immediately
1119
+ if (priority === 'high') {
1120
+ await this.flushQueue(sessionId);
1121
+ return;
1122
+ }
1123
+ // Normal/low: batch for 3 seconds
1124
+ if (!this.flushTimers.has(sessionId)) {
1125
+ const timer = setTimeout(() => this.flushQueue(sessionId), 3000);
1126
+ this.flushTimers.set(sessionId, timer);
1127
+ }
1128
+ }
1129
+ async flushQueue(sessionId) {
1130
+ const timer = this.flushTimers.get(sessionId);
1131
+ if (timer) {
1132
+ clearTimeout(timer);
1133
+ this.flushTimers.delete(sessionId);
1134
+ }
1135
+ const items = this.messageQueue.get(sessionId);
1136
+ if (!items || items.length === 0)
1137
+ return;
1138
+ this.messageQueue.delete(sessionId);
1139
+ // Group low-priority items together, send high/normal separately
1140
+ const groups = [];
1141
+ let lowBatch = [];
1142
+ for (const item of items) {
1143
+ if (item.priority === 'low') {
1144
+ lowBatch.push(item.text);
1145
+ }
1146
+ else {
1147
+ // Flush any pending low-priority batch first
1148
+ if (lowBatch.length > 0) {
1149
+ groups.push(lowBatch.join('\n'));
1150
+ lowBatch = [];
1151
+ }
1152
+ groups.push(item.text);
1153
+ }
1154
+ }
1155
+ if (lowBatch.length > 0) {
1156
+ groups.push(lowBatch.join('\n'));
1157
+ }
1158
+ for (let i = 0; i < groups.length; i++) {
1159
+ await this.sendToTopic(sessionId, groups[i]);
1160
+ }
1161
+ }
1162
+ cleanupSessionRuntimeState(sessionId) {
1163
+ this.progress.delete(sessionId);
1164
+ this.lastSent.delete(sessionId);
1165
+ this.pendingTool.delete(sessionId);
1166
+ this.pendingReads.delete(sessionId);
1167
+ this.preTopicBuffer.delete(sessionId);
1168
+ this.lastUserMessage.delete(sessionId);
1169
+ this.inFlightCount.delete(sessionId);
1170
+ const ft = this.flushTimers.get(sessionId);
1171
+ if (ft) {
1172
+ clearTimeout(ft);
1173
+ this.flushTimers.delete(sessionId);
1174
+ }
1175
+ const rt = this.readTimer.get(sessionId);
1176
+ if (rt) {
1177
+ clearTimeout(rt);
1178
+ this.readTimer.delete(sessionId);
1179
+ }
1180
+ }
1181
+ startTopicCleanupSweep() {
1182
+ if (this.topicCleanupSweepTimer)
1183
+ return;
1184
+ const sweepMs = Math.min(60_000, Math.max(5_000, this.topicTtlMs || 5_000));
1185
+ this.topicCleanupSweepTimer = setInterval(() => {
1186
+ for (const [sessionId, topic] of this.topics) {
1187
+ if (topic.endedAt === null)
1188
+ continue;
1189
+ if (topic.deleting)
1190
+ continue;
1191
+ if (Date.now() >= topic.endedAt + this.topicTtlMs) {
1192
+ void this.runTopicCleanup(sessionId);
1193
+ }
1194
+ }
1195
+ }, sweepMs);
1196
+ if (typeof this.topicCleanupSweepTimer.unref === 'function') {
1197
+ this.topicCleanupSweepTimer.unref();
1198
+ }
1199
+ }
1200
+ clearTopicCleanupTimer(sessionId) {
1201
+ const timer = this.topicCleanupTimers.get(sessionId);
1202
+ if (timer) {
1203
+ clearTimeout(timer);
1204
+ this.topicCleanupTimers.delete(sessionId);
1205
+ }
1206
+ }
1207
+ scheduleTopicCleanup(sessionId) {
1208
+ const topic = this.topics.get(sessionId);
1209
+ if (!topic)
1210
+ return;
1211
+ if (topic.endedAt === null) {
1212
+ topic.endedAt = Date.now();
1213
+ }
1214
+ if (topic.cleanupScheduledAt !== null) {
1215
+ return;
1216
+ }
1217
+ const cleanupAt = topic.endedAt + this.topicTtlMs;
1218
+ topic.cleanupScheduledAt = cleanupAt;
1219
+ const delayMs = Math.max(0, cleanupAt - Date.now());
1220
+ if (delayMs === 0) {
1221
+ void this.runTopicCleanup(sessionId);
1222
+ return;
1223
+ }
1224
+ this.clearTopicCleanupTimer(sessionId);
1225
+ const timer = setTimeout(() => {
1226
+ void this.runTopicCleanup(sessionId);
1227
+ }, delayMs);
1228
+ this.topicCleanupTimers.set(sessionId, timer);
1229
+ if (typeof timer.unref === 'function') {
1230
+ timer.unref();
1231
+ }
1232
+ }
1233
+ async runTopicCleanup(sessionId) {
1234
+ const topic = this.topics.get(sessionId);
1235
+ if (!topic || topic.endedAt === null || topic.deleting)
1236
+ return;
1237
+ if (Date.now() < topic.endedAt + this.topicTtlMs) {
1238
+ return;
1239
+ }
1240
+ topic.deleting = true;
1241
+ this.clearTopicCleanupTimer(sessionId);
1242
+ const body = {
1243
+ chat_id: this.config.groupChatId,
1244
+ message_thread_id: topic.topicId,
1245
+ };
1246
+ try {
1247
+ try {
1248
+ await this.tgApi('closeForumTopic', body);
1249
+ }
1250
+ catch (e) {
1251
+ if (!this.isIgnorableTopicDeleteError(e)) {
1252
+ throw e;
1253
+ }
1254
+ }
1255
+ await this.tgApi('deleteForumTopic', body);
1256
+ this.topics.delete(sessionId);
1257
+ }
1258
+ catch (e) {
1259
+ if (this.isIgnorableTopicDeleteError(e)) {
1260
+ this.topics.delete(sessionId);
1261
+ }
1262
+ else {
1263
+ console.error(`Telegram: failed to cleanup topic for session ${sessionId}:`, this.redactError(e));
1264
+ topic.deleting = false;
1265
+ topic.cleanupScheduledAt = null;
1266
+ const timer = setTimeout(() => {
1267
+ void this.runTopicCleanup(sessionId);
1268
+ }, TelegramChannel.TOPIC_CLEANUP_RETRY_MS);
1269
+ this.topicCleanupTimers.set(sessionId, timer);
1270
+ if (typeof timer.unref === 'function') {
1271
+ timer.unref();
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1276
+ isIgnorableTopicDeleteError(err) {
1277
+ const message = err instanceof Error ? err.message : String(err);
1278
+ return /not found|message thread|topic.*(?:closed|deleted)|forum topic/i.test(message);
1279
+ }
1280
+ // ── /swarm command ──────────────────────────────────────────────────
1281
+ async handleSwarmCommand(sessionId) {
1282
+ if (!this.swarmMonitor) {
1283
+ await this.sendImmediate(sessionId, '⚠️ Swarm monitoring not available');
1284
+ return;
1285
+ }
1286
+ const swarm = this.swarmMonitor.findSwarmByParentSessionId(sessionId);
1287
+ if (!swarm || swarm.teammates.length === 0) {
1288
+ await this.sendImmediate(sessionId, '🐝 No active swarm teammates');
1289
+ return;
1290
+ }
1291
+ const statusEmoji = {
1292
+ running: '🔄',
1293
+ idle: '💤',
1294
+ dead: '💀',
1295
+ };
1296
+ const lines = [`🐝 ${bold('Swarm')} ${swarm.teammates.length} teammate${swarm.teammates.length !== 1 ? 's' : ''}\n`];
1297
+ for (const t of swarm.teammates) {
1298
+ const emoji = statusEmoji[t.status] || '❓';
1299
+ lines.push(`${emoji} ${bold(t.windowName)} ${code(t.windowId)} ${t.status}`);
1300
+ }
1301
+ await this.sendImmediate(sessionId, lines.join('\n'));
1302
+ }
1303
+ // ── Bidirectional polling ─────────────────────────────────────────────────
1304
+ async pollLoop() {
1305
+ while (this.polling) {
1306
+ try {
1307
+ const updates = (await this.tgApi('getUpdates', {
1308
+ offset: this.pollOffset,
1309
+ timeout: 10,
1310
+ allowed_updates: ['message', 'callback_query'],
1311
+ }));
1312
+ // Reset backoff on successful response
1313
+ this.pollBackoffMs = 1_000;
1314
+ if (Array.isArray(updates)) {
1315
+ for (const update of updates) {
1316
+ this.pollOffset = update.update_id + 1;
1317
+ await this.handleUpdate(update);
1318
+ }
1319
+ }
1320
+ }
1321
+ catch (e) {
1322
+ console.error('Telegram poll error:', this.redactError(e));
1323
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
1324
+ const backoffMs = Math.min(this.pollBackoffMs, 30_000);
1325
+ console.log(`Telegram poll: backing off ${backoffMs}ms before retry`);
1326
+ await sleep(backoffMs);
1327
+ this.pollBackoffMs = Math.min(backoffMs * 2, 30_000);
1328
+ }
1329
+ }
1330
+ }
1331
+ /** Issue #348: Redact bot token from error messages before logging. */
1332
+ redactError(err) {
1333
+ const token = this.config.botToken;
1334
+ if (!token)
1335
+ return err;
1336
+ const str = typeof err === 'string' ? err : err instanceof Error ? err.message : String(err);
1337
+ if (!str.includes(token))
1338
+ return err;
1339
+ const redacted = str.replaceAll(token, 'REDACTED');
1340
+ return err instanceof Error
1341
+ ? new Error(`${redacted}\n[stack redacted]`)
1342
+ : redacted;
1343
+ }
1344
+ async handleUpdate(update) {
1345
+ // Handle callback queries from inline buttons
1346
+ if (update.callback_query) {
1347
+ await this.handleCallbackQuery(update.callback_query);
1348
+ return;
1349
+ }
1350
+ const msg = update.message;
1351
+ if (!msg?.text || !msg.message_thread_id || msg.from?.is_bot)
1352
+ return;
1353
+ // Issue #348: Check user against allowlist
1354
+ if (this.config.allowedUserIds.length > 0) {
1355
+ const userId = msg.from?.id;
1356
+ if (!userId || !this.config.allowedUserIds.includes(userId)) {
1357
+ const name = msg.from?.first_name ?? 'Unknown';
1358
+ console.warn(`Telegram: rejected unauthorized user ${name} (${userId ?? 'no id'})`);
1359
+ // Send warning in the topic
1360
+ for (const [, topic] of this.topics) {
1361
+ if (topic.topicId === msg.message_thread_id) {
1362
+ await this.sendImmediate(topic.sessionId, `⛔ Unauthorized: ${esc(name)} is not in the allowed users list`);
1363
+ break;
1364
+ }
1365
+ }
1366
+ return;
1367
+ }
1368
+ }
1369
+ for (const [sessionId, topic] of this.topics) {
1370
+ if (topic.topicId === msg.message_thread_id) {
1371
+ const text = msg.text.trim().toLowerCase();
1372
+ const raw = msg.text.trim();
1373
+ if (text === 'approve' || text === 'y' || text === 'yes') {
1374
+ await this.onInbound?.({ sessionId, action: 'approve' });
1375
+ }
1376
+ else if (text === 'reject' || text === 'n' || text === 'no') {
1377
+ await this.onInbound?.({ sessionId, action: 'reject' });
1378
+ }
1379
+ else if (text === 'escape' || text === 'esc') {
1380
+ await this.onInbound?.({ sessionId, action: 'escape' });
1381
+ }
1382
+ else if (text === 'kill' || text === 'stop') {
1383
+ await this.onInbound?.({ sessionId, action: 'kill' });
1384
+ }
1385
+ else if (text === '/swarm') {
1386
+ await this.handleSwarmCommand(sessionId);
1387
+ }
1388
+ else if (raw.startsWith('/')) {
1389
+ await this.onInbound?.({ sessionId, action: 'command', text: raw });
1390
+ }
1391
+ else {
1392
+ await this.onInbound?.({ sessionId, action: 'message', text: raw });
1393
+ }
1394
+ break;
1395
+ }
1396
+ }
1397
+ }
1398
+ async handleCallbackQuery(cbQuery) {
1399
+ const cb = cbQuery;
1400
+ if (!cb.data || !cb.message?.message_thread_id)
1401
+ return;
1402
+ // Issue #348: Check user against allowlist for callbacks too
1403
+ if (this.config.allowedUserIds.length > 0) {
1404
+ const userId = cb.from?.id;
1405
+ if (!userId || !this.config.allowedUserIds.includes(userId)) {
1406
+ const name = cb.from?.first_name ?? 'Unknown';
1407
+ console.warn(`Telegram: rejected unauthorized callback from ${name} (${userId ?? 'no id'})`);
1408
+ try {
1409
+ await this.tgApi('answerCallbackQuery', {
1410
+ callback_query_id: cb.id,
1411
+ text: '⛔ You are not authorized to use this bot',
1412
+ show_alert: true,
1413
+ });
1414
+ }
1415
+ catch { /* non-critical */ }
1416
+ return;
1417
+ }
1418
+ }
1419
+ // Answer the callback to remove loading state
1420
+ try {
1421
+ await this.tgApi('answerCallbackQuery', { callback_query_id: cb.id });
1422
+ }
1423
+ catch { /* non-critical */ }
1424
+ // Route callback to the right session
1425
+ for (const [sessionId, topic] of this.topics) {
1426
+ if (topic.topicId === cb.message.message_thread_id) {
1427
+ const data = cb.data;
1428
+ if (data.startsWith('perm_approve:')) {
1429
+ await this.onInbound?.({ sessionId, action: 'approve' });
1430
+ // Remove buttons after one-shot action
1431
+ if (cb.message.message_id) {
1432
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1433
+ }
1434
+ }
1435
+ else if (data.startsWith('perm_reject:')) {
1436
+ await this.onInbound?.({ sessionId, action: 'reject' });
1437
+ if (cb.message.message_id) {
1438
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1439
+ }
1440
+ }
1441
+ else if (data.startsWith('cb_option:')) {
1442
+ // Dynamic option from parseOptions: extract value after sessionId:
1443
+ const optParts = data.split(':');
1444
+ const optValue = optParts.slice(2).join(':');
1445
+ // Issue #348: Validate option value is numeric (matches parseOptions numbered output)
1446
+ if (!/^\d+$/.test(optValue)) {
1447
+ console.warn(`Telegram: rejected non-numeric cb_option value "${optValue}"`);
1448
+ break;
1449
+ }
1450
+ await this.onInbound?.({ sessionId, action: 'message', text: optValue });
1451
+ if (cb.message.message_id) {
1452
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1453
+ }
1454
+ }
1455
+ else if (data.startsWith('cb_yes:')) {
1456
+ await this.onInbound?.({ sessionId, action: 'message', text: 'yes' });
1457
+ if (cb.message.message_id) {
1458
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1459
+ }
1460
+ }
1461
+ else if (data.startsWith('cb_no:')) {
1462
+ await this.onInbound?.({ sessionId, action: 'message', text: 'no' });
1463
+ if (cb.message.message_id) {
1464
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1465
+ }
1466
+ }
1467
+ else if (data.startsWith('cb_skip:')) {
1468
+ await this.onInbound?.({ sessionId, action: 'message', text: 'skip' });
1469
+ if (cb.message.message_id) {
1470
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1471
+ }
1472
+ }
1473
+ else if (data.startsWith('plan_exec:')) {
1474
+ await this.onInbound?.({ sessionId, action: 'message', text: 'Execute the plan step by step' });
1475
+ if (cb.message.message_id) {
1476
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1477
+ }
1478
+ }
1479
+ else if (data.startsWith('plan_exec_all:')) {
1480
+ await this.onInbound?.({ sessionId, action: 'message', text: 'Execute all phases of the plan' });
1481
+ if (cb.message.message_id) {
1482
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1483
+ }
1484
+ }
1485
+ else if (data.startsWith('plan_cancel:')) {
1486
+ await this.onInbound?.({ sessionId, action: 'escape' });
1487
+ if (cb.message.message_id) {
1488
+ await this.removeReplyMarkup(sessionId, cb.message.message_id);
1489
+ }
1490
+ }
1491
+ else {
1492
+ // Generic callback → forward as command
1493
+ await this.onInbound?.({ sessionId, action: 'command', text: data });
1494
+ }
1495
+ break;
1496
+ }
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Remove inline keyboard from a message after button click (one-shot actions).
1501
+ */
1502
+ async removeReplyMarkup(sessionId, messageId) {
1503
+ const topic = this.topics.get(sessionId);
1504
+ if (!topic)
1505
+ return;
1506
+ try {
1507
+ await this.tgApi('editMessageReplyMarkup', {
1508
+ chat_id: this.config.groupChatId,
1509
+ message_id: messageId,
1510
+ reply_markup: JSON.stringify({ inline_keyboard: [] }),
1511
+ });
1512
+ }
1513
+ catch { /* non-critical — message may be too old or already edited */ }
1514
+ }
1515
+ }
1516
+ function sleep(ms) {
1517
+ return new Promise(resolve => setTimeout(resolve, ms));
1518
+ }