codeclaw 0.1.0 → 0.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/README.md +165 -170
- package/dist/bot-telegram.js +652 -0
- package/dist/bot.js +288 -0
- package/dist/channel-base.js +30 -0
- package/dist/channel-telegram.js +474 -0
- package/dist/cli.js +209 -0
- package/dist/code-agent.js +472 -0
- package/package.json +8 -4
- package/bin/codeclaw.js +0 -3
- package/src/channel-telegram.js +0 -1070
- package/src/codeclaw.js +0 -781
package/src/channel-telegram.js
DELETED
|
@@ -1,1070 +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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 <prompt> \u2014 Ask the AI agent\n' +
|
|
454
|
-
'/engine [codex|claude] \u2014 Show or switch engine\n' +
|
|
455
|
-
'/battle <prompt> \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 <name> | new <name> | del <name>\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 <name>\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 <name> [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 <name>\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 <prompt>', { 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 <question>', { replyTo: messageId, parseMode: 'HTML' });
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
text = arg;
|
|
947
|
-
} else {
|
|
948
|
-
await this._sendMessage(chatId, 'Unknown command. /help for usage.', { replyTo: messageId });
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// Normal message
|
|
954
|
-
await this._runPrompt(chatId, text, messageId);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// -------------------------------------------------------------------
|
|
958
|
-
// Startup notice
|
|
959
|
-
// -------------------------------------------------------------------
|
|
960
|
-
|
|
961
|
-
async _sendStartupNotice() {
|
|
962
|
-
const { execSync } = await import('node:child_process');
|
|
963
|
-
const targets = new Set(this.core.allowedChatIds);
|
|
964
|
-
for (const key of Object.keys(this.core.state.chats || {})) {
|
|
965
|
-
const n = parseInt(key, 10);
|
|
966
|
-
if (!Number.isNaN(n)) targets.add(n);
|
|
967
|
-
}
|
|
968
|
-
if (!targets.size) {
|
|
969
|
-
this.core._log('startup notice: no known chats yet \u2014 send any message to the bot first, or set --allowed-ids');
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
let whichCmd;
|
|
974
|
-
try { whichCmd = (cmd) => execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim(); }
|
|
975
|
-
catch { whichCmd = () => null; }
|
|
976
|
-
|
|
977
|
-
const engines = [...VALID_ENGINES].sort().filter(eng => { try { return !!whichCmd(eng); } catch { return false; } });
|
|
978
|
-
const engineList = engines.length ? engines.join(', ') : 'none found';
|
|
979
|
-
const statusLine = this.core._replacedOldProcess ? 'restarted (replaced previous instance)' : 'online';
|
|
980
|
-
const text = (
|
|
981
|
-
`<b>codeclaw</b> v${VERSION} ${escapeHtml(statusLine)}\n` +
|
|
982
|
-
'\n' +
|
|
983
|
-
`<b>Engine:</b> ${escapeHtml(this.core.defaultEngine)}\n` +
|
|
984
|
-
`<b>Available:</b> ${escapeHtml(engineList)}\n` +
|
|
985
|
-
`<b>Workdir:</b> <code>${escapeHtml(this.core.workdir)}</code>\n` +
|
|
986
|
-
'\n' +
|
|
987
|
-
'<i>/help for commands</i>'
|
|
988
|
-
);
|
|
989
|
-
for (const cid of [...targets].sort((a, b) => a - b)) {
|
|
990
|
-
try {
|
|
991
|
-
await this._sendMessage(cid, text, { parseMode: 'HTML' });
|
|
992
|
-
this.core._log(`startup notice sent to chat=${cid}`);
|
|
993
|
-
} catch (exc) {
|
|
994
|
-
this.core._log(`startup notice failed for chat=${cid}: ${exc}`, { err: true });
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// -------------------------------------------------------------------
|
|
1000
|
-
// Polling loop
|
|
1001
|
-
// -------------------------------------------------------------------
|
|
1002
|
-
|
|
1003
|
-
async run() {
|
|
1004
|
-
await this._sendStartupNotice();
|
|
1005
|
-
this.core._log(`polling started (mention_required=${this.core.requireMention})`);
|
|
1006
|
-
|
|
1007
|
-
while (this.core.running) {
|
|
1008
|
-
const offset = (parseInt(this.core.state.last_update_id, 10) || 0) + 1;
|
|
1009
|
-
let updates;
|
|
1010
|
-
try {
|
|
1011
|
-
const data = await this._apiCall('getUpdates', {
|
|
1012
|
-
timeout: this.core.pollTimeout,
|
|
1013
|
-
offset,
|
|
1014
|
-
allowed_updates: ['message', 'callback_query'],
|
|
1015
|
-
});
|
|
1016
|
-
updates = data.result || [];
|
|
1017
|
-
} catch (exc) {
|
|
1018
|
-
const isNetwork = String(exc).toLowerCase().includes('fetch') || String(exc).toLowerCase().includes('network');
|
|
1019
|
-
this.core._log(`${isNetwork ? 'network' : 'poll'} error: ${exc}`, { err: true });
|
|
1020
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
for (const update of updates) {
|
|
1025
|
-
const uid = parseInt(update.update_id, 10) || 0;
|
|
1026
|
-
this.core.state.last_update_id = Math.max(
|
|
1027
|
-
parseInt(this.core.state.last_update_id, 10) || 0, uid
|
|
1028
|
-
);
|
|
1029
|
-
this.core._saveState();
|
|
1030
|
-
|
|
1031
|
-
// Callback queries
|
|
1032
|
-
const cq = update.callback_query;
|
|
1033
|
-
if (cq && typeof cq === 'object') {
|
|
1034
|
-
try { await this._handleCallbackQuery(cq); } catch (exc) {
|
|
1035
|
-
this.core._log(`callback_query error: ${exc}`, { err: true });
|
|
1036
|
-
}
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
const msg = update.message;
|
|
1041
|
-
if (!msg || typeof msg !== 'object' || !this._shouldHandle(msg)) continue;
|
|
1042
|
-
|
|
1043
|
-
const chatId = msg.chat.id;
|
|
1044
|
-
const user = msg.from || {};
|
|
1045
|
-
const username = user.username || user.first_name || '';
|
|
1046
|
-
const preview = (msg.text || msg.caption || '').trim().replace(/\n/g, ' ').slice(0, 100);
|
|
1047
|
-
this.core._log(`msg chat=${chatId} user=${username} ${JSON.stringify(preview)}`);
|
|
1048
|
-
|
|
1049
|
-
try {
|
|
1050
|
-
if (msg.photo && !(msg.text || '').startsWith('/')) {
|
|
1051
|
-
await this._handlePhotoMessage(msg);
|
|
1052
|
-
} else {
|
|
1053
|
-
await this._handleTextMessage(msg);
|
|
1054
|
-
}
|
|
1055
|
-
} catch (exc) {
|
|
1056
|
-
this.core._log(`error chat=${chatId}: ${exc}`, { err: true });
|
|
1057
|
-
const eng = this.core._engineForChat(Number(chatId));
|
|
1058
|
-
const [sn, tid] = this.core._sessionForChat(Number(chatId));
|
|
1059
|
-
const meta = this._formatMetaHtml(sn, tid, eng);
|
|
1060
|
-
const isTimeout = String(exc).includes('timeout') || String(exc).includes('Timeout');
|
|
1061
|
-
const errMsg = isTimeout
|
|
1062
|
-
? `Timeout (>${this.core.runTimeout}s)\n\n${meta}`
|
|
1063
|
-
: `Error: ${escapeHtml(String(exc))}\n\n${meta}`;
|
|
1064
|
-
await this._sendMessage(Number(chatId), errMsg, { replyTo: msg.message_id, parseMode: 'HTML' })
|
|
1065
|
-
.catch(() => {});
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
}
|