aegis-bridge 2.2.2

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