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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- 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, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>');
|
|
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
|