codeclaw 0.2.1 → 0.2.3

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.
@@ -1,1097 +0,0 @@
1
- /**
2
- * Telegram channel — all Telegram Bot API interaction for codeclaw.
3
- *
4
- * Handles: messaging, formatting, inline keyboards, pagination,
5
- * callback queries, photo/document handling, streaming display,
6
- * interactive prompt detection, and the polling loop.
7
- */
8
-
9
- import crypto from 'node:crypto';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import { VERSION, VALID_ENGINES, normalizeSessionName, normalizeEngine } from './codeclaw.js';
13
-
14
- // ---------------------------------------------------------------------------
15
- // Telegram formatting helpers
16
- // ---------------------------------------------------------------------------
17
-
18
- function escapeHtml(text) {
19
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
20
- }
21
-
22
- function mdToTgHtml(text) {
23
- const result = [];
24
- const lines = text.split('\n');
25
- let i = 0;
26
- let inCodeBlock = false;
27
- let codeLang = '';
28
- let codeLines = [];
29
-
30
- while (i < lines.length) {
31
- const line = lines[i];
32
- const stripped = line.trim();
33
-
34
- if (stripped.startsWith('```')) {
35
- if (!inCodeBlock) {
36
- inCodeBlock = true;
37
- const remainder = stripped.slice(3).trim();
38
- codeLang = remainder ? remainder.split(/\s/)[0] : '';
39
- codeLines = [];
40
- } else {
41
- inCodeBlock = false;
42
- const codeContent = escapeHtml(codeLines.join('\n'));
43
- if (codeLang) {
44
- result.push(`<pre><code class="language-${escapeHtml(codeLang)}">${codeContent}</code></pre>`);
45
- } else {
46
- result.push(`<pre>${codeContent}</pre>`);
47
- }
48
- }
49
- i++;
50
- continue;
51
- }
52
-
53
- if (inCodeBlock) {
54
- codeLines.push(line);
55
- i++;
56
- continue;
57
- }
58
-
59
- const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
60
- if (headingMatch) {
61
- result.push(`<b>${mdInlineToHtml(headingMatch[2])}</b>`);
62
- i++;
63
- continue;
64
- }
65
-
66
- result.push(mdInlineToHtml(line));
67
- i++;
68
- }
69
-
70
- if (inCodeBlock && codeLines.length) {
71
- const codeContent = escapeHtml(codeLines.join('\n'));
72
- result.push(`<pre>${codeContent}</pre>`);
73
- }
74
-
75
- return result.join('\n');
76
- }
77
-
78
- function mdInlineToHtml(line) {
79
- const parts = [];
80
- let remaining = line;
81
- while (remaining.includes('`')) {
82
- const idx = remaining.indexOf('`');
83
- const end = remaining.indexOf('`', idx + 1);
84
- if (end === -1) break;
85
- parts.push(formatTextSegment(remaining.slice(0, idx)));
86
- parts.push(`<code>${escapeHtml(remaining.slice(idx + 1, end))}</code>`);
87
- remaining = remaining.slice(end + 1);
88
- }
89
- parts.push(formatTextSegment(remaining));
90
- return parts.join('');
91
- }
92
-
93
- function formatTextSegment(text) {
94
- text = escapeHtml(text);
95
- text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
96
- text = text.replace(/__(.+?)__/g, '<b>$1</b>');
97
- text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<i>$1</i>');
98
- text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<i>$1</i>');
99
- text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
100
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
101
- return text;
102
- }
103
-
104
- function trimText(text, limit = 3900) {
105
- text = text.trim();
106
- if (!text) return ['(empty response)'];
107
- if (text.length <= limit) return [text];
108
- const chunks = [];
109
- let remaining = text;
110
- while (remaining.length > limit) {
111
- let cut = remaining.lastIndexOf('\n', limit);
112
- if (cut < 0) cut = limit;
113
- chunks.push(remaining.slice(0, cut).trim());
114
- remaining = remaining.slice(cut).trimStart();
115
- }
116
- if (remaining) chunks.push(remaining);
117
- return chunks;
118
- }
119
-
120
- function detectQuickReplies(text) {
121
- const lines = text.trim().split('\n');
122
- const lastLines = lines.slice(-15).join('\n');
123
-
124
- if (/\?\s*$/.test(lastLines)) {
125
- if (/(?:should I|do you want|shall I|would you like|proceed|continue\?)/i.test(lastLines)) {
126
- return ['Yes', 'No'];
127
- }
128
- }
129
-
130
- const numbered = [...lastLines.matchAll(/^\s*(\d+)[.)]\s+(.{3,60})$/gm)];
131
- if (numbered.length >= 2 && numbered.length <= 6) {
132
- return numbered.map(m => `${m[1]}. ${m[2].trim().slice(0, 30)}`);
133
- }
134
-
135
- const lettered = [...lastLines.matchAll(/^\s*([A-F])[.)]\s+(.{3,60})$/gm)];
136
- if (lettered.length >= 2 && lettered.length <= 6) {
137
- return lettered.map(m => `${m[1]}) ${m[2].trim().slice(0, 30)}`);
138
- }
139
-
140
- return [];
141
- }
142
-
143
- // ---------------------------------------------------------------------------
144
- // Telegram Channel
145
- // ---------------------------------------------------------------------------
146
-
147
- export class TelegramChannel {
148
- static EDIT_INTERVAL = 1.5;
149
-
150
- constructor(core) {
151
- this.core = core;
152
- this.token = core.token;
153
- this.apiBase = `https://api.telegram.org/bot${this.token}`;
154
- this._pageCache = new Map();
155
- }
156
-
157
- // -------------------------------------------------------------------
158
- // Telegram Bot API
159
- // -------------------------------------------------------------------
160
-
161
- async _apiCall(method, payload) {
162
- const url = `${this.apiBase}/${method}`;
163
- const resp = await fetch(url, {
164
- method: 'POST',
165
- headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify(payload || {}),
167
- signal: AbortSignal.timeout(Math.max(30, this.core.pollTimeout + 10) * 1000),
168
- });
169
- const data = await resp.json();
170
- if (!data.ok) {
171
- throw new Error(`Telegram API error (${method}): ${JSON.stringify(data)}`);
172
- }
173
- return data;
174
- }
175
-
176
- async _sendMessage(chatId, text, { replyTo, parseMode, replyMarkup } = {}) {
177
- let msgId = null;
178
- for (const chunk of trimText(text)) {
179
- const payload = {
180
- chat_id: chatId,
181
- text: chunk,
182
- disable_web_page_preview: true,
183
- };
184
- if (parseMode) payload.parse_mode = parseMode;
185
- if (replyTo != null) payload.reply_to_message_id = replyTo;
186
- if (replyMarkup != null) payload.reply_markup = replyMarkup;
187
- let result;
188
- try {
189
- result = await this._apiCall('sendMessage', payload);
190
- } catch {
191
- if (parseMode) {
192
- delete payload.parse_mode;
193
- result = await this._apiCall('sendMessage', payload);
194
- } else {
195
- throw new Error('sendMessage failed');
196
- }
197
- }
198
- if (msgId === null) {
199
- msgId = result?.result?.message_id ?? null;
200
- }
201
- }
202
- return msgId;
203
- }
204
-
205
- async _deleteMessage(chatId, messageId) {
206
- try {
207
- await this._apiCall('deleteMessage', { chat_id: chatId, message_id: messageId });
208
- return true;
209
- } catch {
210
- return false;
211
- }
212
- }
213
-
214
- async _editMessage(chatId, messageId, text, { parseMode, replyMarkup } = {}) {
215
- text = text.trim();
216
- if (!text) return;
217
- if (text.length > 4000) text = text.slice(0, 4000) + '\n...';
218
- const payload = {
219
- chat_id: chatId,
220
- message_id: messageId,
221
- text,
222
- disable_web_page_preview: true,
223
- };
224
- if (parseMode) payload.parse_mode = parseMode;
225
- if (replyMarkup != null) payload.reply_markup = replyMarkup;
226
- try {
227
- await this._apiCall('editMessageText', payload);
228
- } catch (exc) {
229
- const errStr = String(exc).toLowerCase();
230
- if (errStr.includes('message is not modified')) return;
231
- if (parseMode && (errStr.includes("can't parse") || errStr.includes('bad request'))) {
232
- delete payload.parse_mode;
233
- try { await this._apiCall('editMessageText', payload); return; } catch { /* ignore */ }
234
- }
235
- this.core._log(`edit error: ${exc}`, { err: true });
236
- }
237
- }
238
-
239
- async _answerCallbackQuery(callbackQueryId, text = '') {
240
- const payload = { callback_query_id: callbackQueryId };
241
- if (text) payload.text = text;
242
- try { await this._apiCall('answerCallbackQuery', payload); } catch { /* ignore */ }
243
- }
244
-
245
- async _sendDocument(chatId, content, filename, { caption, replyTo } = {}) {
246
- const url = `${this.apiBase}/sendDocument`;
247
- const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 16);
248
- const boundary = `----codeclaw${hash}`;
249
- const parts = [];
250
-
251
- parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}`);
252
- if (replyTo) {
253
- parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${replyTo}`);
254
- }
255
- if (caption) {
256
- parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${caption.slice(0, 1024)}`);
257
- }
258
-
259
- const fileHeader = `--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
260
- const bodyParts = parts.map(p => Buffer.from(p, 'utf-8'));
261
- bodyParts.push(Buffer.concat([Buffer.from(fileHeader, 'utf-8'), Buffer.from(content, 'utf-8')]));
262
- bodyParts.push(Buffer.from(`--${boundary}--\r\n`, 'utf-8'));
263
-
264
- const body = Buffer.concat(bodyParts.map((p, i) =>
265
- i < bodyParts.length - 1 ? Buffer.concat([p, Buffer.from('\r\n')]) : p
266
- ));
267
-
268
- try {
269
- const resp = await fetch(url, {
270
- method: 'POST',
271
- headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
272
- body,
273
- });
274
- const data = await resp.json();
275
- return data?.result?.message_id ?? null;
276
- } catch (exc) {
277
- this.core._log(`sendDocument error: ${exc}`, { err: true });
278
- return null;
279
- }
280
- }
281
-
282
- async _getFileUrl(fileId) {
283
- try {
284
- const data = await this._apiCall('getFile', { file_id: fileId });
285
- const filePath = data?.result?.file_path || '';
286
- if (filePath) return `https://api.telegram.org/file/bot${this.token}/${filePath}`;
287
- } catch (exc) {
288
- this.core._log(`getFile error: ${exc}`, { err: true });
289
- }
290
- return null;
291
- }
292
-
293
- async _downloadFile(fileUrl) {
294
- try {
295
- const resp = await fetch(fileUrl, { signal: AbortSignal.timeout(30000) });
296
- return Buffer.from(await resp.arrayBuffer());
297
- } catch (exc) {
298
- this.core._log(`download error: ${exc}`, { err: true });
299
- return null;
300
- }
301
- }
302
-
303
- // -------------------------------------------------------------------
304
- // Formatting
305
- // -------------------------------------------------------------------
306
-
307
- _fmtTokens(n) {
308
- if (n == null) return '-';
309
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
310
- return String(n);
311
- }
312
-
313
- _sessionSummaryHtml(chatId) {
314
- const cs = this.core._ensureChatState(chatId);
315
- const active = cs.active;
316
- const engine = cs.engine || this.core.defaultEngine;
317
- const lines = [
318
- `<b>Engine:</b> ${escapeHtml(engine)}`,
319
- `<b>Active:</b> ${escapeHtml(active)}`,
320
- '',
321
- ];
322
- for (const name of Object.keys(cs.threads).sort()) {
323
- const tid = (cs.threads[name] || '').trim();
324
- const marker = name === active ? ' (active)' : '';
325
- const tidDisplay = tid ? `<code>${escapeHtml(tid.slice(0, 12))}</code>` : '-';
326
- lines.push(` ${escapeHtml(name)}${marker} ${tidDisplay}`);
327
- }
328
- return lines.join('\n');
329
- }
330
-
331
- _formatMetaHtml(sessionName, threadId, engine, result) {
332
- const parts = [engine];
333
- if (result) {
334
- parts.push(`${result.elapsedS.toFixed(1)}s`);
335
- const { inputTokens: inT, cachedInputTokens: caT, outputTokens: ouT } = result;
336
- if (inT != null || ouT != null) {
337
- const tokenParts = [];
338
- if (inT != null) tokenParts.push(`in:${this._fmtTokens(inT)}`);
339
- if (caT) tokenParts.push(`cached:${this._fmtTokens(caT)}`);
340
- if (ouT != null) tokenParts.push(`out:${this._fmtTokens(ouT)}`);
341
- parts.push(tokenParts.join(' '));
342
- }
343
- }
344
- const tid = threadId || (result ? result.threadId : null);
345
- if (tid) parts.push(tid.slice(0, 12));
346
- return '<code>' + parts.join(' | ') + '</code>';
347
- }
348
-
349
- // -------------------------------------------------------------------
350
- // Pagination & keyboards
351
- // -------------------------------------------------------------------
352
-
353
- _paginateText(text, limit = 3800) {
354
- if (text.length <= limit) return [text];
355
- const pages = [];
356
- let remaining = text;
357
- while (remaining.length > limit) {
358
- let cut = remaining.lastIndexOf('\n', limit);
359
- if (cut < 0) cut = limit;
360
- pages.push(remaining.slice(0, cut).trim());
361
- remaining = remaining.slice(cut).trimStart();
362
- }
363
- if (remaining.trim()) pages.push(remaining.trim());
364
- return pages;
365
- }
366
-
367
- _buildPageKeyboard(msgId, page, total, quickReplies) {
368
- const navRow = [];
369
- if (page > 0) navRow.push({ text: '< Prev', callback_data: `page:${msgId}:${page - 1}` });
370
- navRow.push({ text: `${page + 1}/${total}`, callback_data: 'noop' });
371
- if (page < total - 1) navRow.push({ text: 'Next >', callback_data: `page:${msgId}:${page + 1}` });
372
- const rows = [navRow];
373
- const actionRow = [];
374
- if (total > 1) actionRow.push({ text: 'Full text', callback_data: `full:${msgId}` });
375
- actionRow.push({ text: 'New session', callback_data: `newsess:${msgId}` });
376
- rows.push(actionRow);
377
- if (quickReplies?.length) rows.push(...this._buildQuickReplyRows(msgId, quickReplies));
378
- return { inline_keyboard: rows };
379
- }
380
-
381
- _buildActionKeyboard(msgId, quickReplies) {
382
- const row = [{ text: 'New session', callback_data: `newsess:${msgId}` }];
383
- const rows = [row];
384
- if (quickReplies?.length) rows.push(...this._buildQuickReplyRows(msgId, quickReplies));
385
- return { inline_keyboard: rows };
386
- }
387
-
388
- _buildQuickReplyRows(msgId, replies) {
389
- const rows = [];
390
- let row = [];
391
- for (let i = 0; i < replies.length; i++) {
392
- const label = replies[i].slice(0, 32);
393
- let cbData = `qr:${msgId}:${i}`;
394
- if (cbData.length > 64) cbData = cbData.slice(0, 64);
395
- row.push({ text: label, callback_data: cbData });
396
- if (row.length >= 3) { rows.push(row); row = []; }
397
- }
398
- if (row.length) rows.push(row);
399
- return rows;
400
- }
401
-
402
- _cachePages(msgId, chatId, pages, meta, sessionName, engine, rawMessage = '', quickReplies) {
403
- this._pageCache.set(msgId, {
404
- pages, meta, chatId, sessionName, engine,
405
- fullText: pages.join('\n'),
406
- rawMessage,
407
- quickReplies: quickReplies || [],
408
- });
409
- if (this._pageCache.size > 50) {
410
- const keys = [...this._pageCache.keys()].slice(0, this._pageCache.size - 50);
411
- for (const k of keys) this._pageCache.delete(k);
412
- }
413
- }
414
-
415
- // -------------------------------------------------------------------
416
- // Final reply
417
- // -------------------------------------------------------------------
418
-
419
- async _sendFinalReply(chatId, placeholderMsgId, sessionName, engine, result) {
420
- const meta = this._formatMetaHtml(sessionName, result.threadId, engine, result);
421
- const body = mdToTgHtml(result.message);
422
- const pages = this._paginateText(body, 3800);
423
- const total = pages.length;
424
- const quickReplies = detectQuickReplies(result.message);
425
-
426
- if (total === 1) {
427
- const htmlText = `${pages[0]}\n\n${meta}`;
428
- const keyboard = this._buildActionKeyboard(placeholderMsgId, quickReplies);
429
- await this._editMessage(chatId, placeholderMsgId, htmlText, { parseMode: 'HTML', replyMarkup: keyboard });
430
- } else {
431
- const pageHeader = `<i>Page 1/${total}</i>`;
432
- const htmlText = `${pages[0]}\n\n${pageHeader}\n${meta}`;
433
- const keyboard = this._buildPageKeyboard(placeholderMsgId, 0, total, quickReplies);
434
- await this._editMessage(chatId, placeholderMsgId, htmlText, { parseMode: 'HTML', replyMarkup: keyboard });
435
- this._cachePages(placeholderMsgId, chatId, pages, meta, sessionName, engine, result.message, quickReplies);
436
- await this._sendDocument(
437
- chatId, result.message,
438
- `response_${placeholderMsgId}.md`,
439
- { caption: `Full response (${result.message.length} chars)`, replyTo: placeholderMsgId },
440
- );
441
- }
442
- }
443
-
444
- // -------------------------------------------------------------------
445
- // Help
446
- // -------------------------------------------------------------------
447
-
448
- _helpHtml() {
449
- return (
450
- `<b>codeclaw</b> v${VERSION}\n` +
451
- '\n' +
452
- '<b>Commands</b>\n' +
453
- '/ask &lt;prompt&gt; \u2014 Ask the AI agent\n' +
454
- '/engine [codex|claude] \u2014 Show or switch engine\n' +
455
- '/battle &lt;prompt&gt; \u2014 Run both engines, compare\n' +
456
- '/new [prompt] \u2014 Reset session\n' +
457
- '/stop \u2014 Clear session thread\n' +
458
- '/status \u2014 Session / engine / thread info\n' +
459
- '/session list|use|new|del \u2014 Multi-session\n' +
460
- '/clear [N] \u2014 Delete bot\'s recent messages (default 50)\n' +
461
- '\n' +
462
- '<i>DM: send text directly. Group: @mention or reply.\n' +
463
- 'Send photos with a caption to analyze images.</i>'
464
- );
465
- }
466
-
467
- // -------------------------------------------------------------------
468
- // Streaming
469
- // -------------------------------------------------------------------
470
-
471
- async _streamRun(chatId, placeholderMsgId, prompt, threadId, engine) {
472
- const proc = this.core.spawnEngine(prompt, engine, threadId);
473
- const start = Date.now();
474
- let lastEdit = 0;
475
- let editCount = 0;
476
- const editInterval = TelegramChannel.EDIT_INTERVAL * 1000;
477
-
478
- const onText = (text) => {
479
- const now = Date.now();
480
- if ((now - lastEdit) < editInterval) return;
481
- const display = text.trim();
482
- if (!display) return;
483
- const elapsed = (now - start) / 1000;
484
- const maxBody = 3600;
485
- let bodyHtml;
486
- if (display.length > maxBody) {
487
- const truncated = display.slice(-maxBody);
488
- bodyHtml = '<i>(...truncated)</i>\n' + mdToTgHtml(truncated);
489
- } else {
490
- bodyHtml = mdToTgHtml(display);
491
- }
492
- const dots = '\u00b7'.repeat((editCount % 3) + 1);
493
- const header = `<code>${escapeHtml(engine)} | ${elapsed.toFixed(0)}s ${dots}</code>`;
494
- const htmlText = `${bodyHtml}\n\n${header}`;
495
- this._editMessage(chatId, placeholderMsgId, htmlText, { parseMode: 'HTML' })
496
- .catch(exc => this.core._log(`stream edit failed: ${exc}`, { err: true }));
497
- lastEdit = now;
498
- editCount++;
499
- };
500
-
501
- const result = await this.core.parseEvents(proc, engine, threadId, onText);
502
- this.core._log(
503
- `done engine=${engine} ok=${result.ok} ` +
504
- `elapsed=${result.elapsedS.toFixed(1)}s edits=${editCount} ` +
505
- `tokens=in:${this._fmtTokens(result.inputTokens)}` +
506
- `/cached:${this._fmtTokens(result.cachedInputTokens)}` +
507
- `/out:${this._fmtTokens(result.outputTokens)}`
508
- );
509
- return result;
510
- }
511
-
512
- async _runBlocking(prompt, engine) {
513
- const proc = this.core.spawnEngine(prompt, engine, null);
514
- return this.core.parseEvents(proc, engine, null, () => {});
515
- }
516
-
517
- // -------------------------------------------------------------------
518
- // Battle mode
519
- // -------------------------------------------------------------------
520
-
521
- async _handleBattle(chatId, messageId, prompt) {
522
- const engines = [...VALID_ENGINES].sort();
523
- this.core._log(`battle started: ${engines[0]} vs ${engines[1]}`);
524
- const placeholderId = await this._sendMessage(
525
- chatId,
526
- `<b>BATTLE</b> ${escapeHtml(engines[0])} vs ${escapeHtml(engines[1])}\n\n<i>Running both engines...</i>`,
527
- { replyTo: messageId, parseMode: 'HTML' },
528
- );
529
-
530
- const results = {};
531
- const errors = {};
532
-
533
- await Promise.all(engines.map(async (eng) => {
534
- try {
535
- results[eng] = await this._runBlocking(prompt, eng);
536
- } catch (exc) {
537
- errors[eng] = String(exc);
538
- }
539
- }));
540
-
541
- const parts = [`<b>BATTLE</b> ${escapeHtml(prompt.slice(0, 80))}\n`];
542
- for (const eng of engines) {
543
- const r = results[eng];
544
- const err = errors[eng];
545
- parts.push(`<b>\u258e${escapeHtml(eng.toUpperCase())}</b>`);
546
- if (err) {
547
- parts.push(`Error: ${escapeHtml(err)}`);
548
- } else if (r) {
549
- let stats = `${r.elapsedS.toFixed(1)}s`;
550
- if (r.inputTokens != null && r.outputTokens != null) {
551
- stats += ` | ${this._fmtTokens(r.inputTokens + r.outputTokens)} tokens`;
552
- }
553
- parts.push(`${mdToTgHtml(r.message)}\n<code>${stats}</code>`);
554
- } else {
555
- parts.push('(no result)');
556
- }
557
- parts.push('');
558
- }
559
-
560
- for (const eng of engines) {
561
- const r = results[eng];
562
- if (r) this.core._log(`battle ${eng}: ${r.elapsedS.toFixed(1)}s ok=${r.ok} tokens=out:${this._fmtTokens(r.outputTokens)}`);
563
- }
564
-
565
- const fullText = parts.join('\n').trim();
566
- const chunks = trimText(fullText, 3800);
567
- await this._editMessage(chatId, placeholderId, chunks[0], { parseMode: 'HTML' });
568
- for (const chunk of chunks.slice(1)) {
569
- await this._sendMessage(chatId, chunk, { parseMode: 'HTML' });
570
- }
571
- }
572
-
573
- // -------------------------------------------------------------------
574
- // Message routing
575
- // -------------------------------------------------------------------
576
-
577
- _shouldHandle(msg) {
578
- const chat = msg.chat || {};
579
- const chatId = chat.id;
580
- if (chatId == null) return false;
581
- if (this.core.allowedChatIds.size && !this.core.allowedChatIds.has(Number(chatId))) return false;
582
- const chatType = chat.type || '';
583
- const text = (msg.text || msg.caption || '').trim();
584
- const hasPhoto = !!msg.photo;
585
- const hasDocument = !!msg.document;
586
- if (chatType === 'private') return !!(text || hasPhoto || hasDocument);
587
- if (text.startsWith('/')) return true;
588
- if (!this.core.requireMention) return !!(text || hasPhoto || hasDocument);
589
- const mention = this.core.botUsername ? `@${this.core.botUsername.toLowerCase()}` : '';
590
- if (mention && text.toLowerCase().includes(mention)) return true;
591
- const replyTo = msg.reply_to_message || {};
592
- if (replyTo.from?.id === this.core.botId) return true;
593
- return false;
594
- }
595
-
596
- _cleanPrompt(text) {
597
- if (this.core.botUsername) {
598
- text = text.replace(new RegExp(`@${this.core.botUsername}`, 'gi'), '');
599
- }
600
- return text.trim();
601
- }
602
-
603
- // -------------------------------------------------------------------
604
- // Callback query handler
605
- // -------------------------------------------------------------------
606
-
607
- async _handleCallbackQuery(cq) {
608
- const cqId = cq.id || '';
609
- const data = cq.data || '';
610
- const msg = cq.message || {};
611
- const chatId = msg.chat?.id;
612
- const messageId = msg.message_id;
613
- if (!chatId || !messageId) { await this._answerCallbackQuery(cqId); return; }
614
-
615
- if (data === 'noop') { await this._answerCallbackQuery(cqId); return; }
616
-
617
- // Pagination
618
- if (data.startsWith('page:')) {
619
- const p = data.split(':');
620
- if (p.length === 3) {
621
- const cacheId = parseInt(p[1], 10);
622
- let pageNum = parseInt(p[2], 10);
623
- if (Number.isNaN(cacheId) || Number.isNaN(pageNum)) {
624
- await this._answerCallbackQuery(cqId, 'Invalid page'); return;
625
- }
626
- const entry = this._pageCache.get(cacheId);
627
- if (!entry) { await this._answerCallbackQuery(cqId, 'Page expired, send message again'); return; }
628
- const { pages, meta } = entry;
629
- const total = pages.length;
630
- pageNum = Math.max(0, Math.min(pageNum, total - 1));
631
- const pageHeader = `<i>Page ${pageNum + 1}/${total}</i>`;
632
- const htmlText = `${pages[pageNum]}\n\n${pageHeader}\n${meta}`;
633
- const keyboard = this._buildPageKeyboard(cacheId, pageNum, total, entry.quickReplies);
634
- await this._editMessage(chatId, messageId, htmlText, { parseMode: 'HTML', replyMarkup: keyboard });
635
- await this._answerCallbackQuery(cqId, `Page ${pageNum + 1}/${total}`);
636
- }
637
- return;
638
- }
639
-
640
- // Full text as document
641
- if (data.startsWith('full:')) {
642
- const cacheId = parseInt(data.split(':')[1], 10);
643
- if (Number.isNaN(cacheId)) { await this._answerCallbackQuery(cqId); return; }
644
- const entry = this._pageCache.get(cacheId);
645
- if (!entry) { await this._answerCallbackQuery(cqId, 'Cache expired'); return; }
646
- await this._sendDocument(
647
- chatId, entry.rawMessage || entry.fullText,
648
- `response_${cacheId}.md`,
649
- { caption: 'Full response' },
650
- );
651
- await this._answerCallbackQuery(cqId, 'Sent as document');
652
- return;
653
- }
654
-
655
- // Quick reply
656
- if (data.startsWith('qr:')) {
657
- const p = data.split(':');
658
- if (p.length === 3) {
659
- const cacheId = parseInt(p[1], 10);
660
- const idx = parseInt(p[2], 10);
661
- if (Number.isNaN(cacheId) || Number.isNaN(idx)) { await this._answerCallbackQuery(cqId); return; }
662
- const entry = this._pageCache.get(cacheId);
663
- const replies = entry?.quickReplies || [];
664
- const replyText = idx < replies.length ? replies[idx] : `Option ${idx + 1}`;
665
- await this._answerCallbackQuery(cqId, `Sending: ${replyText.slice(0, 40)}`);
666
- await this._runPrompt(chatId, replyText, messageId);
667
- }
668
- return;
669
- }
670
-
671
- // New session
672
- if (data.startsWith('newsess:')) {
673
- const [sessionName] = this.core._sessionForChat(chatId);
674
- this.core._setSessionThread(chatId, sessionName, null);
675
- const engine = this.core._engineForChat(chatId);
676
- const meta = this._formatMetaHtml(sessionName, null, engine);
677
- await this._answerCallbackQuery(cqId, 'Session reset');
678
- await this._sendMessage(
679
- chatId,
680
- `Session reset: <b>${escapeHtml(sessionName)}</b>\n\n${meta}`,
681
- { parseMode: 'HTML' },
682
- );
683
- return;
684
- }
685
-
686
- await this._answerCallbackQuery(cqId);
687
- }
688
-
689
- // -------------------------------------------------------------------
690
- // Photo handler
691
- // -------------------------------------------------------------------
692
-
693
- async _handlePhotoMessage(msg) {
694
- const chatId = Number(msg.chat.id);
695
- const messageId = msg.message_id;
696
- const caption = this._cleanPrompt((msg.caption || '').trim());
697
-
698
- const photos = msg.photo || [];
699
- if (!photos.length) return;
700
- const bestPhoto = photos.reduce((a, b) => (b.file_size || 0) > (a.file_size || 0) ? b : a);
701
- const fileId = bestPhoto.file_id;
702
- if (!fileId) return;
703
-
704
- const engine = this.core._engineForChat(chatId);
705
- const ph = await this._sendMessage(
706
- chatId,
707
- `<code>${escapeHtml(engine)} | downloading image ...</code>`,
708
- { replyTo: messageId, parseMode: 'HTML' },
709
- );
710
-
711
- const fileUrl = await this._getFileUrl(fileId);
712
- if (!fileUrl) { await this._editMessage(chatId, ph, 'Failed to download image.'); return; }
713
-
714
- const fileData = await this._downloadFile(fileUrl);
715
- if (!fileData) { await this._editMessage(chatId, ph, 'Failed to download image.'); return; }
716
-
717
- const ext = fileUrl.endsWith('.png') ? '.png' : '.jpg';
718
- const tmpPath = path.join(this.core.workdir, `_tg_photo_${messageId}${ext}`);
719
- try {
720
- fs.writeFileSync(tmpPath, fileData);
721
- let prompt = caption || 'Please analyze this image.';
722
- prompt = `${prompt}\n\n[Image saved to: ${path.basename(tmpPath)}]`;
723
-
724
- await this._editMessage(
725
- chatId, ph,
726
- `<code>${escapeHtml(engine)} | thinking ...</code>`,
727
- { parseMode: 'HTML' },
728
- );
729
-
730
- const [sessionName, currentThread] = this.core._sessionForChat(chatId);
731
- const result = await this._streamRun(chatId, ph, prompt, currentThread, engine);
732
- if (result.threadId) this.core._setSessionThread(chatId, sessionName, result.threadId);
733
- await this._sendFinalReply(chatId, ph, sessionName, engine, result);
734
- } finally {
735
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
736
- }
737
- }
738
-
739
- // -------------------------------------------------------------------
740
- // Prompt runner
741
- // -------------------------------------------------------------------
742
-
743
- async _runPrompt(chatId, prompt, replyTo) {
744
- const engine = this.core._engineForChat(chatId);
745
- const [sessionName, currentThread] = this.core._sessionForChat(chatId);
746
- const ph = await this._sendMessage(
747
- chatId,
748
- `<code>${escapeHtml(engine)} | thinking ...</code>`,
749
- { replyTo, parseMode: 'HTML' },
750
- );
751
- const result = await this._streamRun(chatId, ph, prompt, currentThread, engine);
752
- if (result.threadId) this.core._setSessionThread(chatId, sessionName, result.threadId);
753
- await this._sendFinalReply(chatId, ph, sessionName, engine, result);
754
- }
755
-
756
- // -------------------------------------------------------------------
757
- // Command handlers
758
- // -------------------------------------------------------------------
759
-
760
- _handleSessionCommand(chatId, arg, engine) {
761
- const [active, tid] = this.core._sessionForChat(chatId);
762
- const defaultMeta = this._formatMetaHtml(active, tid, engine);
763
- const parts = arg.split(/\s+/).filter(Boolean);
764
- if (!parts.length) {
765
- return `Usage: /session list | use &lt;name&gt; | new &lt;name&gt; | del &lt;name&gt;\n\n${defaultMeta}`;
766
- }
767
-
768
- const action = parts[0].toLowerCase();
769
- if (action === 'list') return `${this._sessionSummaryHtml(chatId)}\n\n${defaultMeta}`;
770
-
771
- if (action === 'use') {
772
- if (parts.length < 2) return `Usage: /session use &lt;name&gt;\n\n${defaultMeta}`;
773
- const name = normalizeSessionName(parts[1]);
774
- this.core._setActiveSession(chatId, name);
775
- const [, newTid] = this.core._sessionForChat(chatId);
776
- const meta = this._formatMetaHtml(name, newTid, engine);
777
- return `Switched to session: <b>${escapeHtml(name)}</b>\n\n${meta}`;
778
- }
779
-
780
- if (action === 'new') {
781
- if (parts.length < 2) return `Usage: /session new &lt;name&gt; [prompt]\n\n${defaultMeta}`;
782
- const name = normalizeSessionName(parts[1]);
783
- this.core._setActiveSession(chatId, name);
784
- this.core._setSessionThread(chatId, name, null);
785
- if (!parts.slice(2).join(' ').trim()) {
786
- const meta = this._formatMetaHtml(name, null, engine);
787
- return `Created session: <b>${escapeHtml(name)}</b>\n\n${meta}`;
788
- }
789
- return null;
790
- }
791
-
792
- if (['del', 'delete', 'rm'].includes(action)) {
793
- if (parts.length < 2) return `Usage: /session del &lt;name&gt;\n\n${defaultMeta}`;
794
- this.core._deleteSession(chatId, normalizeSessionName(parts[1]));
795
- const [newActive, newTid] = this.core._sessionForChat(chatId);
796
- const meta = this._formatMetaHtml(newActive, newTid, engine);
797
- return `Deleted session: <b>${escapeHtml(parts[1])}</b>\n\n${meta}`;
798
- }
799
-
800
- return `Unknown subcommand.\n\n${defaultMeta}`;
801
- }
802
-
803
- async _handleTextMessage(msg) {
804
- const chatId = Number(msg.chat.id);
805
- const messageId = msg.message_id;
806
- let text = this._cleanPrompt((msg.text || msg.caption || '').trim());
807
- if (!text) return;
808
- const engine = this.core._engineForChat(chatId);
809
-
810
- if (text.startsWith('/')) {
811
- const spaceIdx = text.indexOf(' ');
812
- const head = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
813
- const arg = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1).trim();
814
- let cmd = head.slice(1);
815
- if (cmd.includes('@')) cmd = cmd.split('@')[0];
816
-
817
- if (cmd === 'start' || cmd === 'help') {
818
- const [sessionName, tid] = this.core._sessionForChat(chatId);
819
- const meta = this._formatMetaHtml(sessionName, tid, engine);
820
- await this._sendMessage(chatId, `${this._helpHtml()}\n\n${meta}`, { replyTo: messageId, parseMode: 'HTML' });
821
- return;
822
- }
823
-
824
- if (cmd === 'engine') {
825
- if (!arg) {
826
- const avail = [...VALID_ENGINES].sort().join(', ');
827
- await this._sendMessage(
828
- chatId,
829
- `<b>Engine:</b> ${escapeHtml(engine)}\n<b>Available:</b> ${escapeHtml(avail)}\n\n/engine codex or /engine claude`,
830
- { replyTo: messageId, parseMode: 'HTML' },
831
- );
832
- return;
833
- }
834
- try {
835
- const newEngine = normalizeEngine(arg);
836
- this.core._setEngineForChat(chatId, newEngine);
837
- this.core._log(`engine switched to ${newEngine} chat=${chatId}`);
838
- const [sessionName, tid] = this.core._sessionForChat(chatId);
839
- const meta = this._formatMetaHtml(sessionName, tid, newEngine);
840
- await this._sendMessage(
841
- chatId,
842
- `Engine switched to <b>${escapeHtml(newEngine)}</b>\n\n${meta}`,
843
- { replyTo: messageId, parseMode: 'HTML' },
844
- );
845
- } catch (exc) {
846
- await this._sendMessage(chatId, String(exc), { replyTo: messageId });
847
- }
848
- return;
849
- }
850
-
851
- if (cmd === 'battle') {
852
- if (!arg) {
853
- await this._sendMessage(chatId, 'Usage: /battle &lt;prompt&gt;', { replyTo: messageId, parseMode: 'HTML' });
854
- return;
855
- }
856
- await this._handleBattle(chatId, messageId, arg);
857
- return;
858
- }
859
-
860
- if (cmd === 'session' || cmd === 'sessions') {
861
- const reply = this._handleSessionCommand(chatId, arg, engine);
862
- if (reply !== null) {
863
- await this._sendMessage(chatId, reply, { replyTo: messageId, parseMode: 'HTML' });
864
- return;
865
- }
866
- const cmdParts = arg.split(/\s+/).filter(Boolean);
867
- const sessionName = normalizeSessionName(cmdParts[1]);
868
- const prompt = cmdParts.slice(2).join(' ').trim();
869
- const ph = await this._sendMessage(
870
- chatId,
871
- `<code>${escapeHtml(engine)} | thinking ...</code>`,
872
- { replyTo: messageId, parseMode: 'HTML' },
873
- );
874
- const result = await this._streamRun(chatId, ph, prompt, null, engine);
875
- this.core._setSessionThread(chatId, sessionName, result.threadId);
876
- await this._sendFinalReply(chatId, ph, sessionName, engine, result);
877
- return;
878
- }
879
-
880
- if (cmd === 'new' || cmd === 'reset') {
881
- const [sessionName] = this.core._sessionForChat(chatId);
882
- this.core._setSessionThread(chatId, sessionName, null);
883
- if (!arg) {
884
- const meta = this._formatMetaHtml(sessionName, null, engine);
885
- await this._sendMessage(
886
- chatId,
887
- `Session reset: <b>${escapeHtml(sessionName)}</b>\n\n${meta}`,
888
- { replyTo: messageId, parseMode: 'HTML' },
889
- );
890
- return;
891
- }
892
- const ph = await this._sendMessage(
893
- chatId,
894
- `<code>${escapeHtml(engine)} | thinking ...</code>`,
895
- { replyTo: messageId, parseMode: 'HTML' },
896
- );
897
- const result = await this._streamRun(chatId, ph, arg, null, engine);
898
- this.core._setSessionThread(chatId, sessionName, result.threadId);
899
- await this._sendFinalReply(chatId, ph, sessionName, engine, result);
900
- return;
901
- }
902
-
903
- if (cmd === 'stop') {
904
- const [sessionName] = this.core._sessionForChat(chatId);
905
- this.core._setSessionThread(chatId, sessionName, null);
906
- const meta = this._formatMetaHtml(sessionName, null, engine);
907
- await this._sendMessage(
908
- chatId,
909
- `Session cleared: <b>${escapeHtml(sessionName)}</b>\n\n${meta}`,
910
- { replyTo: messageId, parseMode: 'HTML' },
911
- );
912
- return;
913
- }
914
-
915
- if (cmd === 'status') {
916
- const [sessionName, tid] = this.core._sessionForChat(chatId);
917
- const summary = this._sessionSummaryHtml(chatId);
918
- const meta = this._formatMetaHtml(sessionName, tid, engine);
919
- await this._sendMessage(chatId, `${summary}\n\n${meta}`, { replyTo: messageId, parseMode: 'HTML' });
920
- return;
921
- }
922
-
923
- if (cmd === 'clear') {
924
- let count = 50;
925
- if (arg) { const n = parseInt(arg, 10); if (!Number.isNaN(n)) count = Math.min(n, 200); }
926
- this.core._log(`clear: deleting up to ${count} bot messages in chat=${chatId}`);
927
- let deleted = 0;
928
- if (messageId) await this._deleteMessage(chatId, messageId);
929
- if (messageId) {
930
- for (let offset = 1; offset <= count; offset++) {
931
- const mid = messageId - offset;
932
- if (mid <= 0) break;
933
- if (await this._deleteMessage(chatId, mid)) deleted++;
934
- }
935
- }
936
- this.core._log(`clear: deleted ${deleted} messages in chat=${chatId}`);
937
- await this._sendMessage(chatId, `<i>Cleared ${deleted} messages.</i>`, { parseMode: 'HTML' });
938
- return;
939
- }
940
-
941
- if (cmd === 'ask' || cmd === 'a') {
942
- if (!arg) {
943
- await this._sendMessage(chatId, 'Usage: /ask &lt;question&gt;', { replyTo: messageId, parseMode: 'HTML' });
944
- return;
945
- }
946
- text = arg;
947
- } else {
948
- // Pass through unrecognized commands to the engine (e.g. /commit, /simplify, /compact)
949
- text = `/${cmd}` + (arg ? ` ${arg}` : '');
950
- }
951
- }
952
-
953
- // Normal message
954
- await this._runPrompt(chatId, text, messageId);
955
- }
956
-
957
- // -------------------------------------------------------------------
958
- // Startup notice
959
- // -------------------------------------------------------------------
960
-
961
- async _registerBotCommands() {
962
- const commands = [
963
- // codeclaw native commands
964
- { command: 'ask', description: 'Ask the AI agent' },
965
- { command: 'engine', description: 'Show or switch engine (codex/claude)' },
966
- { command: 'battle', description: 'Run both engines and compare' },
967
- { command: 'new', description: 'Reset session (optionally with prompt)' },
968
- { command: 'stop', description: 'Clear session thread' },
969
- { command: 'status', description: 'Session / engine / thread info' },
970
- { command: 'session', description: 'Multi-session: list|use|new|del' },
971
- { command: 'clear', description: 'Delete bot messages (default 50)' },
972
- { command: 'help', description: 'Show help' },
973
- // Engine passthrough commands (claude / codex)
974
- { command: 'compact', description: '[engine] Compact conversation context' },
975
- { command: 'commit', description: '[engine] Generate a git commit' },
976
- { command: 'simplify', description: '[engine] Review and simplify changed code' },
977
- { command: 'pr_comments', description: '[engine] Address PR review comments' },
978
- ];
979
- try {
980
- await this._apiCall('setMyCommands', { commands });
981
- this.core._log(`registered ${commands.length} bot commands`);
982
- } catch (exc) {
983
- this.core._log(`setMyCommands failed: ${exc}`, { err: true });
984
- }
985
- }
986
-
987
- async _sendStartupNotice() {
988
- const { execSync } = await import('node:child_process');
989
- const targets = new Set(this.core.allowedChatIds);
990
- for (const key of Object.keys(this.core.state.chats || {})) {
991
- const n = parseInt(key, 10);
992
- if (!Number.isNaN(n)) targets.add(n);
993
- }
994
- if (!targets.size) {
995
- this.core._log('startup notice: no known chats yet \u2014 send any message to the bot first, or set --allowed-ids');
996
- return;
997
- }
998
-
999
- let whichCmd;
1000
- try { whichCmd = (cmd) => execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim(); }
1001
- catch { whichCmd = () => null; }
1002
-
1003
- const engines = [...VALID_ENGINES].sort().filter(eng => { try { return !!whichCmd(eng); } catch { return false; } });
1004
- const engineList = engines.length ? engines.join(', ') : 'none found';
1005
- const statusLine = this.core._replacedOldProcess ? 'restarted (replaced previous instance)' : 'online';
1006
- const text = (
1007
- `<b>codeclaw</b> v${VERSION} ${escapeHtml(statusLine)}\n` +
1008
- '\n' +
1009
- `<b>Engine:</b> ${escapeHtml(this.core.defaultEngine)}\n` +
1010
- `<b>Available:</b> ${escapeHtml(engineList)}\n` +
1011
- `<b>Workdir:</b> <code>${escapeHtml(this.core.workdir)}</code>\n` +
1012
- '\n' +
1013
- '<i>/help for commands</i>'
1014
- );
1015
- for (const cid of [...targets].sort((a, b) => a - b)) {
1016
- try {
1017
- await this._sendMessage(cid, text, { parseMode: 'HTML' });
1018
- this.core._log(`startup notice sent to chat=${cid}`);
1019
- } catch (exc) {
1020
- this.core._log(`startup notice failed for chat=${cid}: ${exc}`, { err: true });
1021
- }
1022
- }
1023
- }
1024
-
1025
- // -------------------------------------------------------------------
1026
- // Polling loop
1027
- // -------------------------------------------------------------------
1028
-
1029
- async run() {
1030
- await this._registerBotCommands();
1031
- await this._sendStartupNotice();
1032
- this.core._log(`polling started (mention_required=${this.core.requireMention})`);
1033
-
1034
- while (this.core.running) {
1035
- const offset = (parseInt(this.core.state.last_update_id, 10) || 0) + 1;
1036
- let updates;
1037
- try {
1038
- const data = await this._apiCall('getUpdates', {
1039
- timeout: this.core.pollTimeout,
1040
- offset,
1041
- allowed_updates: ['message', 'callback_query'],
1042
- });
1043
- updates = data.result || [];
1044
- } catch (exc) {
1045
- const isNetwork = String(exc).toLowerCase().includes('fetch') || String(exc).toLowerCase().includes('network');
1046
- this.core._log(`${isNetwork ? 'network' : 'poll'} error: ${exc}`, { err: true });
1047
- await new Promise(r => setTimeout(r, 3000));
1048
- continue;
1049
- }
1050
-
1051
- for (const update of updates) {
1052
- const uid = parseInt(update.update_id, 10) || 0;
1053
- this.core.state.last_update_id = Math.max(
1054
- parseInt(this.core.state.last_update_id, 10) || 0, uid
1055
- );
1056
- this.core._saveState();
1057
-
1058
- // Callback queries
1059
- const cq = update.callback_query;
1060
- if (cq && typeof cq === 'object') {
1061
- try { await this._handleCallbackQuery(cq); } catch (exc) {
1062
- this.core._log(`callback_query error: ${exc}`, { err: true });
1063
- }
1064
- continue;
1065
- }
1066
-
1067
- const msg = update.message;
1068
- if (!msg || typeof msg !== 'object' || !this._shouldHandle(msg)) continue;
1069
-
1070
- const chatId = msg.chat.id;
1071
- const user = msg.from || {};
1072
- const username = user.username || user.first_name || '';
1073
- const preview = (msg.text || msg.caption || '').trim().replace(/\n/g, ' ').slice(0, 100);
1074
- this.core._log(`msg chat=${chatId} user=${username} ${JSON.stringify(preview)}`);
1075
-
1076
- try {
1077
- if (msg.photo && !(msg.text || '').startsWith('/')) {
1078
- await this._handlePhotoMessage(msg);
1079
- } else {
1080
- await this._handleTextMessage(msg);
1081
- }
1082
- } catch (exc) {
1083
- this.core._log(`error chat=${chatId}: ${exc}`, { err: true });
1084
- const eng = this.core._engineForChat(Number(chatId));
1085
- const [sn, tid] = this.core._sessionForChat(Number(chatId));
1086
- const meta = this._formatMetaHtml(sn, tid, eng);
1087
- const isTimeout = String(exc).includes('timeout') || String(exc).includes('Timeout');
1088
- const errMsg = isTimeout
1089
- ? `Timeout (&gt;${this.core.runTimeout}s)\n\n${meta}`
1090
- : `Error: ${escapeHtml(String(exc))}\n\n${meta}`;
1091
- await this._sendMessage(Number(chatId), errMsg, { replyTo: msg.message_id, parseMode: 'HTML' })
1092
- .catch(() => {});
1093
- }
1094
- }
1095
- }
1096
- }
1097
- }