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.
@@ -0,0 +1,652 @@
1
+ /**
2
+ * bot-telegram.ts — Telegram-specific bot: formatting, keyboards, callbacks, lifecycle.
3
+ *
4
+ * All Telegram presentation logic lives here. For a new IM (Lark, WhatsApp, ...),
5
+ * create a parallel bot-lark.ts / bot-whatsapp.ts that extends Bot.
6
+ */
7
+ import os from 'node:os';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { spawn } from 'node:child_process';
11
+ import { Bot, VERSION, fmtTokens, fmtUptime, fmtBytes, whichSync, listSubdirs, buildPrompt, thinkLabel, parseAllowedChatIds, shellSplit, } from './bot.js';
12
+ import { TelegramChannel } from './channel-telegram.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Telegram HTML formatting
15
+ // ---------------------------------------------------------------------------
16
+ function escapeHtml(t) {
17
+ return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
18
+ }
19
+ function mdToTgHtml(text) {
20
+ const result = [];
21
+ const lines = text.split('\n');
22
+ let i = 0, inCode = false, codeLang = '', codeLines = [];
23
+ while (i < lines.length) {
24
+ const line = lines[i], stripped = line.trim();
25
+ if (stripped.startsWith('```')) {
26
+ if (!inCode) {
27
+ inCode = true;
28
+ codeLang = stripped.slice(3).trim().split(/\s/)[0] || '';
29
+ codeLines = [];
30
+ }
31
+ else {
32
+ inCode = false;
33
+ const content = escapeHtml(codeLines.join('\n'));
34
+ result.push(codeLang ? `<pre><code class="language-${escapeHtml(codeLang)}">${content}</code></pre>` : `<pre>${content}</pre>`);
35
+ }
36
+ i++;
37
+ continue;
38
+ }
39
+ if (inCode) {
40
+ codeLines.push(line);
41
+ i++;
42
+ continue;
43
+ }
44
+ const hm = line.match(/^(#{1,6})\s+(.+)$/);
45
+ if (hm) {
46
+ result.push(`<b>${mdInline(hm[2])}</b>`);
47
+ i++;
48
+ continue;
49
+ }
50
+ result.push(mdInline(line));
51
+ i++;
52
+ }
53
+ if (inCode && codeLines.length)
54
+ result.push(`<pre>${escapeHtml(codeLines.join('\n'))}</pre>`);
55
+ return result.join('\n');
56
+ }
57
+ function mdInline(line) {
58
+ const parts = [];
59
+ let rest = line;
60
+ while (rest.includes('`')) {
61
+ const a = rest.indexOf('`'), b = rest.indexOf('`', a + 1);
62
+ if (b === -1)
63
+ break;
64
+ parts.push(fmtSeg(rest.slice(0, a)));
65
+ parts.push(`<code>${escapeHtml(rest.slice(a + 1, b))}</code>`);
66
+ rest = rest.slice(b + 1);
67
+ }
68
+ parts.push(fmtSeg(rest));
69
+ return parts.join('');
70
+ }
71
+ function fmtSeg(t) {
72
+ t = escapeHtml(t);
73
+ t = t.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
74
+ t = t.replace(/__(.+?)__/g, '<b>$1</b>');
75
+ t = t.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<i>$1</i>');
76
+ t = t.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<i>$1</i>');
77
+ t = t.replace(/~~(.+?)~~/g, '<s>$1</s>');
78
+ t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
79
+ return t;
80
+ }
81
+ function detectQuickReplies(text) {
82
+ const last = text.trim().split('\n').slice(-15).join('\n');
83
+ if (/\?\s*$/.test(last) && /(?:should I|do you want|shall I|would you like|proceed|continue\?)/i.test(last))
84
+ return ['Yes', 'No'];
85
+ const numbered = [...last.matchAll(/^\s*(\d+)[.)]\s+(.{3,60})$/gm)];
86
+ if (numbered.length >= 2 && numbered.length <= 6)
87
+ return numbered.map(m => `${m[1]}. ${m[2].trim().slice(0, 30)}`);
88
+ return [];
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Directory browser (Telegram callback_data 64-byte limit)
92
+ // ---------------------------------------------------------------------------
93
+ class PathRegistry {
94
+ pathToId = new Map();
95
+ idToPath = new Map();
96
+ nextId = 1;
97
+ register(p) {
98
+ let id = this.pathToId.get(p);
99
+ if (id != null)
100
+ return id;
101
+ id = this.nextId++;
102
+ this.pathToId.set(p, id);
103
+ this.idToPath.set(id, p);
104
+ if (this.pathToId.size > 500) {
105
+ const oldest = [...this.pathToId.entries()].slice(0, 200);
106
+ for (const [k, v] of oldest) {
107
+ this.pathToId.delete(k);
108
+ this.idToPath.delete(v);
109
+ }
110
+ }
111
+ return id;
112
+ }
113
+ resolve(id) {
114
+ return this.idToPath.get(id);
115
+ }
116
+ }
117
+ const pathReg = new PathRegistry();
118
+ const DIR_PAGE_SIZE = 8;
119
+ function buildDirKeyboard(browsePath, page) {
120
+ const dirs = listSubdirs(browsePath);
121
+ const totalPages = Math.max(1, Math.ceil(dirs.length / DIR_PAGE_SIZE));
122
+ const pg = Math.min(Math.max(0, page), totalPages - 1);
123
+ const slice = dirs.slice(pg * DIR_PAGE_SIZE, (pg + 1) * DIR_PAGE_SIZE);
124
+ const rows = [];
125
+ for (let i = 0; i < slice.length; i += 2) {
126
+ const row = [];
127
+ for (let j = i; j < Math.min(i + 2, slice.length); j++) {
128
+ const full = path.join(browsePath, slice[j]);
129
+ const id = pathReg.register(full);
130
+ row.push({ text: slice[j], callback_data: `sw:n:${id}:0` });
131
+ }
132
+ rows.push(row);
133
+ }
134
+ const navRow = [];
135
+ const parent = path.dirname(browsePath);
136
+ if (parent !== browsePath) {
137
+ const pid = pathReg.register(parent);
138
+ navRow.push({ text: '\u2B06 ..', callback_data: `sw:n:${pid}:0` });
139
+ }
140
+ if (totalPages > 1) {
141
+ const bid = pathReg.register(browsePath);
142
+ if (pg > 0)
143
+ navRow.push({ text: `\u25C0 ${pg}/${totalPages}`, callback_data: `sw:n:${bid}:${pg - 1}` });
144
+ if (pg < totalPages - 1)
145
+ navRow.push({ text: `${pg + 2}/${totalPages} \u25B6`, callback_data: `sw:n:${bid}:${pg + 1}` });
146
+ }
147
+ if (navRow.length)
148
+ rows.push(navRow);
149
+ const selId = pathReg.register(browsePath);
150
+ rows.push([{ text: '\u2705 Select this directory', callback_data: `sw:s:${selId}` }]);
151
+ return { inline_keyboard: rows };
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // TelegramBot
155
+ // ---------------------------------------------------------------------------
156
+ export class TelegramBot extends Bot {
157
+ token;
158
+ channel;
159
+ replyCache = new Map();
160
+ constructor() {
161
+ super();
162
+ // merge Telegram-specific allowed IDs into base
163
+ if (process.env.TELEGRAM_ALLOWED_CHAT_IDS) {
164
+ for (const id of parseAllowedChatIds(process.env.TELEGRAM_ALLOWED_CHAT_IDS))
165
+ this.allowedChatIds.add(id);
166
+ }
167
+ this.token = (process.env.TELEGRAM_BOT_TOKEN || process.env.CODECLAW_TOKEN || '').trim();
168
+ if (!this.token)
169
+ throw new Error('Missing token. Set CODECLAW_TOKEN or TELEGRAM_BOT_TOKEN');
170
+ }
171
+ static MENU_COMMANDS = [
172
+ { command: 'sessions', description: 'List / switch sessions' },
173
+ { command: 'agents', description: 'List / switch agents' },
174
+ { command: 'status', description: 'Bot status' },
175
+ { command: 'host', description: 'Host machine info' },
176
+ { command: 'switch', description: 'Switch working directory' },
177
+ { command: 'restart', description: 'Restart with latest version' },
178
+ ];
179
+ /** Register bot menu commands. Called automatically after connect. */
180
+ async setupMenu() {
181
+ await this.channel.setMenu(TelegramBot.MENU_COMMANDS);
182
+ }
183
+ // ---- commands -------------------------------------------------------------
184
+ async cmdStart(ctx) {
185
+ const cs = this.chat(ctx.chatId);
186
+ await ctx.reply(`<b>codeclaw</b> v${VERSION}\n\n` +
187
+ `/sessions \u2014 List / switch sessions\n` +
188
+ `/agents \u2014 List / switch agents\n` +
189
+ `/status \u2014 Bot status\n` +
190
+ `/host \u2014 Host machine info\n` +
191
+ `/switch \u2014 Switch working directory\n` +
192
+ `/restart \u2014 Restart with latest version\n` +
193
+ `\n<b>Agent:</b> ${escapeHtml(cs.agent)} <b>Workdir:</b> <code>${escapeHtml(this.workdir)}</code>`, { parseMode: 'HTML' });
194
+ }
195
+ sessionsPageSize = 5;
196
+ buildSessionsPage(chatId, page) {
197
+ const cs = this.chat(chatId);
198
+ const res = this.fetchSessions(cs.agent);
199
+ const sessions = res.ok ? res.sessions : [];
200
+ const total = sessions.length;
201
+ const totalPages = Math.max(1, Math.ceil(total / this.sessionsPageSize));
202
+ const pg = Math.max(0, Math.min(page, totalPages - 1));
203
+ const slice = sessions.slice(pg * this.sessionsPageSize, (pg + 1) * this.sessionsPageSize);
204
+ const text = `<b>${escapeHtml(cs.agent)} sessions</b> (${total}) p${pg + 1}/${totalPages}`;
205
+ const rows = [];
206
+ for (const s of slice) {
207
+ const isCurrent = s.sessionId === cs.sessionId;
208
+ const icon = s.running ? '🟢' : isCurrent ? '● ' : '';
209
+ const prefix = s.title ? s.title.replace(/\n/g, ' ').slice(0, 10) : s.sessionId.slice(0, 10);
210
+ const time = s.createdAt ? new Date(s.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '?';
211
+ let cbData = `sess:${s.sessionId}`;
212
+ if (cbData.length > 64)
213
+ cbData = cbData.slice(0, 64);
214
+ rows.push([{ text: `${icon}${prefix} ${time}`, callback_data: cbData }]);
215
+ }
216
+ const navRow = [];
217
+ if (pg > 0)
218
+ navRow.push({ text: `◀ ${pg}/${totalPages}`, callback_data: `sp:${pg - 1}` });
219
+ navRow.push({ text: '+ New', callback_data: 'sess:new' });
220
+ if (pg < totalPages - 1)
221
+ navRow.push({ text: `${pg + 2}/${totalPages} ▶`, callback_data: `sp:${pg + 1}` });
222
+ rows.push(navRow);
223
+ return { text, keyboard: { inline_keyboard: rows } };
224
+ }
225
+ async cmdSessions(ctx) {
226
+ const cs = this.chat(ctx.chatId);
227
+ const res = this.fetchSessions(cs.agent);
228
+ if (!res.ok) {
229
+ await ctx.reply(`Error: ${res.error}`);
230
+ return;
231
+ }
232
+ if (!res.sessions.length) {
233
+ await ctx.reply(`No ${cs.agent} sessions found in:\n<code>${escapeHtml(this.workdir)}</code>`, { parseMode: 'HTML' });
234
+ return;
235
+ }
236
+ const { text, keyboard } = this.buildSessionsPage(ctx.chatId, 0);
237
+ await ctx.reply(text, { parseMode: 'HTML', keyboard });
238
+ }
239
+ async cmdStatus(ctx) {
240
+ const d = this.getStatusData(ctx.chatId);
241
+ const lines = [
242
+ `<b>codeclaw</b> v${d.version}\n`,
243
+ `<b>Uptime:</b> ${fmtUptime(d.uptime)}`,
244
+ `<b>Memory:</b> ${(d.memRss / 1024 / 1024).toFixed(0)}MB RSS / ${(d.memHeap / 1024 / 1024).toFixed(0)}MB heap`,
245
+ `<b>PID:</b> ${d.pid}`,
246
+ `<b>Workdir:</b> <code>${escapeHtml(d.workdir)}</code>`,
247
+ '',
248
+ `<b>Agent:</b> ${escapeHtml(d.agent)}`,
249
+ `<b>Model:</b> ${escapeHtml(d.model)}`,
250
+ `<b>Session:</b> ${d.sessionId ? `<code>${d.sessionId.slice(0, 16)}</code>` : '(new)'}`,
251
+ ];
252
+ if (d.running) {
253
+ lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(d.running.prompt.slice(0, 50))}`);
254
+ }
255
+ lines.push('', '<b>Usage</b>', ` Turns: ${d.stats.totalTurns}`);
256
+ if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
257
+ lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
258
+ if (d.stats.totalCachedTokens)
259
+ lines.push(` Cached: ${fmtTokens(d.stats.totalCachedTokens)}`);
260
+ }
261
+ await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
262
+ }
263
+ async cmdSwitch(ctx) {
264
+ const browsePath = path.dirname(this.workdir);
265
+ const kb = buildDirKeyboard(browsePath, 0);
266
+ await ctx.reply(`<b>Switch workdir</b>\nCurrent: <code>${escapeHtml(this.workdir)}</code>\n\nBrowsing: <code>${escapeHtml(browsePath)}</code>`, { parseMode: 'HTML', keyboard: kb });
267
+ }
268
+ async cmdHost(ctx) {
269
+ const d = this.getHostData();
270
+ const lines = [
271
+ `<b>Host</b>\n`,
272
+ `<b>CPU:</b> ${escapeHtml(d.cpuModel)} x${d.cpuCount}`,
273
+ `<b>Memory:</b> ${fmtBytes(d.totalMem - d.freeMem)} / ${fmtBytes(d.totalMem)} (${((1 - d.freeMem / d.totalMem) * 100).toFixed(0)}%)`,
274
+ ];
275
+ if (d.disk)
276
+ lines.push(`<b>Disk:</b> ${escapeHtml(d.disk.used)} used / ${escapeHtml(d.disk.total)} total (${escapeHtml(d.disk.percent)})`);
277
+ lines.push(`\n<b>Process:</b> PID ${d.selfPid} | RSS ${fmtBytes(d.selfRss)} | Heap ${fmtBytes(d.selfHeap)}`);
278
+ if (d.topProcs.length > 1) {
279
+ lines.push(`\n<b>Top Processes:</b>`);
280
+ lines.push(`<pre>${d.topProcs.map(l => escapeHtml(l)).join('\n')}</pre>`);
281
+ }
282
+ await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
283
+ }
284
+ async cmdAgents(ctx) {
285
+ const cs = this.chat(ctx.chatId);
286
+ const res = this.fetchAgents();
287
+ const lines = [`<b>Available Agents</b>\n`];
288
+ const rows = [];
289
+ for (const a of res.agents) {
290
+ const isCurrent = a.agent === cs.agent;
291
+ const status = !a.installed ? '\u274C' : isCurrent ? '\u25CF' : '\u25CB';
292
+ lines.push(`${status} <b>${escapeHtml(a.agent)}</b>${isCurrent ? ' (current)' : ''}`);
293
+ if (a.installed) {
294
+ if (a.version)
295
+ lines.push(` Version: <code>${escapeHtml(a.version)}</code>`);
296
+ if (a.path)
297
+ lines.push(` Path: <code>${escapeHtml(a.path)}</code>`);
298
+ const label = isCurrent ? `\u25CF ${a.agent} (current)` : a.agent;
299
+ rows.push([{ text: label, callback_data: `ag:${a.agent}` }]);
300
+ }
301
+ else {
302
+ lines.push(` Not installed`);
303
+ }
304
+ }
305
+ await ctx.reply(lines.join('\n'), { parseMode: 'HTML', keyboard: { inline_keyboard: rows } });
306
+ }
307
+ async cmdRestart(ctx) {
308
+ const activeTasks = this.activeTasks.size;
309
+ if (activeTasks > 0) {
310
+ await ctx.reply(`⚠ ${activeTasks} task(s) still running. Wait for them to finish or try again.`, { parseMode: 'HTML' });
311
+ return;
312
+ }
313
+ await ctx.reply(`<b>Restarting codeclaw...</b>\n\n` +
314
+ `Pulling latest version via <code>npx codeclaw@latest</code>.\n` +
315
+ `The bot will be back shortly.`, { parseMode: 'HTML' });
316
+ this.performRestart();
317
+ }
318
+ /** Disconnect, spawn a new process, and exit. */
319
+ performRestart() {
320
+ this.log('restart: disconnecting...');
321
+ this.channel.disconnect();
322
+ this.stopKeepAlive();
323
+ const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx codeclaw@latest';
324
+ const [bin, ...baseArgs] = shellSplit(restartCmd);
325
+ const allArgs = [...baseArgs, ...process.argv.slice(2)];
326
+ this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
327
+ const child = spawn(bin, allArgs, {
328
+ stdio: 'inherit',
329
+ detached: true,
330
+ env: process.env,
331
+ });
332
+ child.unref();
333
+ this.log(`restart: new process spawned (PID ${child.pid}), exiting...`);
334
+ process.exit(0);
335
+ }
336
+ // ---- streaming bridge -----------------------------------------------------
337
+ async handleMessage(msg, ctx) {
338
+ const text = msg.text.trim();
339
+ if (!text && !msg.files.length)
340
+ return;
341
+ const cs = this.chat(ctx.chatId);
342
+ const prompt = buildPrompt(text, msg.files);
343
+ this.log(`[handleMessage] chat=${ctx.chatId} agent=${cs.agent} session=${cs.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${msg.files.length}`);
344
+ const phId = await ctx.reply(`<code>${escapeHtml(cs.agent)} | thinking ...</code>`, { parseMode: 'HTML' });
345
+ if (!phId) {
346
+ this.log(`[handleMessage] placeholder null for chat=${ctx.chatId}`);
347
+ return;
348
+ }
349
+ this.log(`[handleMessage] placeholder sent msg_id=${phId}, starting agent stream...`);
350
+ this.activeTasks.set(ctx.chatId, { prompt, startedAt: Date.now() });
351
+ try {
352
+ const start = Date.now();
353
+ let lastEdit = 0, editCount = 0, editPending = false;
354
+ const onText = (text, thinking) => {
355
+ const now = Date.now();
356
+ if ((now - lastEdit) < 1000 || editPending)
357
+ return;
358
+ const display = text.trim(), thinkDisplay = thinking.trim();
359
+ if (!display && !thinkDisplay)
360
+ return;
361
+ const elapsed = ((now - start) / 1000).toFixed(0);
362
+ const maxBody = 3200;
363
+ const parts = [];
364
+ const tLabel = thinkLabel(cs.agent);
365
+ if (thinkDisplay && !display) {
366
+ const preview = thinkDisplay.length > maxBody ? '...\n' + thinkDisplay.slice(-maxBody) : thinkDisplay;
367
+ parts.push(`${tLabel}\n${preview}`);
368
+ }
369
+ else if (display) {
370
+ if (thinkDisplay)
371
+ parts.push(`${tLabel} (${thinkDisplay.length} chars)`);
372
+ const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
373
+ parts.push(preview);
374
+ }
375
+ const dots = '\u00b7'.repeat((editCount % 3) + 1);
376
+ parts.push(`${cs.agent} | ${elapsed}s ${dots}`);
377
+ editPending = true;
378
+ this.channel.editMessage(ctx.chatId, phId, parts.join('\n\n'))
379
+ .catch(e => this.log(`stream edit err: ${e}`))
380
+ .finally(() => { editPending = false; });
381
+ lastEdit = now;
382
+ editCount++;
383
+ };
384
+ const result = await this.runStream(prompt, cs, msg.files, onText);
385
+ this.log(`[handleMessage] done agent=${cs.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${editCount} ` +
386
+ `tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
387
+ this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
388
+ await this.sendFinalReply(ctx, phId, cs.agent, result);
389
+ this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
390
+ }
391
+ finally {
392
+ this.activeTasks.delete(ctx.chatId);
393
+ }
394
+ }
395
+ async sendFinalReply(ctx, phId, agent, result) {
396
+ const metaParts = [agent];
397
+ if (result.model)
398
+ metaParts.push(result.model);
399
+ if (result.elapsedS != null)
400
+ metaParts.push(`${result.elapsedS.toFixed(1)}s`);
401
+ const meta = `<code>${metaParts.join(' \u00b7 ')}</code>`;
402
+ let tokenBlock = '';
403
+ if (result.inputTokens != null || result.outputTokens != null) {
404
+ const tp = [];
405
+ if (result.inputTokens != null)
406
+ tp.push(`in: ${fmtTokens(result.inputTokens)}`);
407
+ if (result.cachedInputTokens)
408
+ tp.push(`cached: ${fmtTokens(result.cachedInputTokens)}`);
409
+ if (result.outputTokens != null)
410
+ tp.push(`out: ${fmtTokens(result.outputTokens)}`);
411
+ tokenBlock = `\n<blockquote expandable>${tp.join(' ')}</blockquote>`;
412
+ }
413
+ const quickReplies = detectQuickReplies(result.message);
414
+ let keyboard = undefined;
415
+ if (quickReplies.length) {
416
+ const rows = [];
417
+ let row = [];
418
+ for (let i = 0; i < quickReplies.length; i++) {
419
+ let cbData = `qr:${phId}:${i}`;
420
+ if (cbData.length > 64)
421
+ cbData = cbData.slice(0, 64);
422
+ row.push({ text: quickReplies[i].slice(0, 32), callback_data: cbData });
423
+ if (row.length >= 3) {
424
+ rows.push(row);
425
+ row = [];
426
+ }
427
+ }
428
+ if (row.length)
429
+ rows.push(row);
430
+ keyboard = { inline_keyboard: rows };
431
+ this.replyCache.set(phId, { chatId: ctx.chatId, quickReplies });
432
+ if (this.replyCache.size > 100) {
433
+ for (const k of [...this.replyCache.keys()].slice(0, this.replyCache.size - 100))
434
+ this.replyCache.delete(k);
435
+ }
436
+ }
437
+ let thinkingHtml = '';
438
+ if (result.thinking) {
439
+ const label = thinkLabel(agent);
440
+ let display = result.thinking;
441
+ if (display.length > 800)
442
+ display = '...\n' + display.slice(-800);
443
+ thinkingHtml = `<blockquote><b>${label}</b>\n${escapeHtml(display)}</blockquote>\n\n`;
444
+ }
445
+ const bodyHtml = mdToTgHtml(result.message);
446
+ const fullHtml = `${thinkingHtml}${bodyHtml}\n\n${meta}${tokenBlock}`;
447
+ if (fullHtml.length <= 3900) {
448
+ try {
449
+ await this.channel.editMessage(ctx.chatId, phId, fullHtml, { parseMode: 'HTML', keyboard });
450
+ }
451
+ catch {
452
+ await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
453
+ }
454
+ }
455
+ else {
456
+ let preview = bodyHtml.slice(0, 3200);
457
+ if (bodyHtml.length > 3200)
458
+ preview += '\n<i>... (see full response below)</i>';
459
+ const previewHtml = `${thinkingHtml}${preview}\n\n${meta}${tokenBlock}`;
460
+ try {
461
+ await this.channel.editMessage(ctx.chatId, phId, previewHtml, { parseMode: 'HTML', keyboard });
462
+ }
463
+ catch {
464
+ await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
465
+ }
466
+ const thinkingMd = result.thinking
467
+ ? `> **${thinkLabel(agent)}**\n${result.thinking.split('\n').map(l => `> ${l}`).join('\n')}\n\n---\n\n`
468
+ : '';
469
+ await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: phId });
470
+ }
471
+ }
472
+ // ---- callbacks ------------------------------------------------------------
473
+ async handleCallback(data, ctx) {
474
+ if (data.startsWith('sw:n:')) {
475
+ const parts = data.slice(5).split(':');
476
+ const browsePath = pathReg.resolve(parseInt(parts[0], 10));
477
+ if (!browsePath) {
478
+ await ctx.answerCallback('Expired, use /switch again');
479
+ return;
480
+ }
481
+ const page = parseInt(parts[1], 10) || 0;
482
+ const kb = buildDirKeyboard(browsePath, page);
483
+ await ctx.editReply(ctx.messageId, `<b>Switch workdir</b>\nCurrent: <code>${escapeHtml(this.workdir)}</code>\n\nBrowsing: <code>${escapeHtml(browsePath)}</code>`, { parseMode: 'HTML', keyboard: kb });
484
+ await ctx.answerCallback();
485
+ return;
486
+ }
487
+ if (data.startsWith('sw:s:')) {
488
+ const dirPath = pathReg.resolve(parseInt(data.slice(5), 10));
489
+ if (!dirPath) {
490
+ await ctx.answerCallback('Expired, use /switch again');
491
+ return;
492
+ }
493
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
494
+ await ctx.answerCallback('Not a valid directory');
495
+ return;
496
+ }
497
+ const oldPath = this.switchWorkdir(dirPath);
498
+ await ctx.answerCallback(`Switched!`);
499
+ await ctx.editReply(ctx.messageId, `<b>Workdir switched</b>\n\n<code>${escapeHtml(oldPath)}</code>\n\u2193\n<code>${escapeHtml(dirPath)}</code>`, { parseMode: 'HTML' });
500
+ return;
501
+ }
502
+ if (data.startsWith('sp:')) {
503
+ const page = parseInt(data.slice(3), 10) || 0;
504
+ const { text, keyboard } = this.buildSessionsPage(ctx.chatId, page);
505
+ await ctx.editReply(ctx.messageId, text, { parseMode: 'HTML', keyboard });
506
+ await ctx.answerCallback('');
507
+ return;
508
+ }
509
+ if (data.startsWith('sess:')) {
510
+ const sessionId = data.slice(5);
511
+ const cs = this.chat(ctx.chatId);
512
+ if (sessionId === 'new') {
513
+ cs.sessionId = null;
514
+ await ctx.answerCallback('New session');
515
+ await ctx.editReply(ctx.messageId, 'Session reset. Send a message to start.', {});
516
+ }
517
+ else {
518
+ cs.sessionId = sessionId;
519
+ await ctx.answerCallback(`Session: ${sessionId.slice(0, 12)}`);
520
+ await ctx.editReply(ctx.messageId, `Switched to session: <code>${escapeHtml(sessionId.slice(0, 16))}</code>`, { parseMode: 'HTML' });
521
+ }
522
+ return;
523
+ }
524
+ if (data.startsWith('ag:')) {
525
+ const agent = data.slice(3);
526
+ const cs = this.chat(ctx.chatId);
527
+ if (cs.agent === agent) {
528
+ await ctx.answerCallback(`Already using ${agent}`);
529
+ return;
530
+ }
531
+ cs.agent = agent;
532
+ cs.sessionId = null;
533
+ this.log(`agent switched to ${agent} chat=${ctx.chatId}`);
534
+ await ctx.answerCallback(`Switched to ${agent}`);
535
+ await ctx.editReply(ctx.messageId, `<b>Switched to ${escapeHtml(agent)}</b>\n\nSession has been reset. Previous conversation history will not carry over.\nSend a message to start a new conversation.`, { parseMode: 'HTML' });
536
+ return;
537
+ }
538
+ if (data.startsWith('qr:')) {
539
+ const parts = data.split(':');
540
+ if (parts.length === 3) {
541
+ const cacheId = parseInt(parts[1], 10);
542
+ const idx = parseInt(parts[2], 10);
543
+ const entry = this.replyCache.get(cacheId);
544
+ const replyText = entry?.quickReplies?.[idx] ?? `Option ${idx + 1}`;
545
+ await ctx.answerCallback(`Sending: ${replyText.slice(0, 40)}`);
546
+ const fakeMsg = { text: replyText, files: [] };
547
+ await this.handleMessage(fakeMsg, ctx);
548
+ }
549
+ return;
550
+ }
551
+ await ctx.answerCallback();
552
+ }
553
+ // ---- command router -------------------------------------------------------
554
+ async handleCommand(cmd, args, ctx) {
555
+ try {
556
+ switch (cmd) {
557
+ case 'start':
558
+ await this.cmdStart(ctx);
559
+ return;
560
+ case 'sessions':
561
+ await this.cmdSessions(ctx);
562
+ return;
563
+ case 'agents':
564
+ await this.cmdAgents(ctx);
565
+ return;
566
+ case 'status':
567
+ await this.cmdStatus(ctx);
568
+ return;
569
+ case 'host':
570
+ await this.cmdHost(ctx);
571
+ return;
572
+ case 'switch':
573
+ await this.cmdSwitch(ctx);
574
+ return;
575
+ case 'restart':
576
+ await this.cmdRestart(ctx);
577
+ return;
578
+ default:
579
+ await this.handleMessage({ text: `/${cmd}${args ? ' ' + args : ''}`, files: [] }, ctx);
580
+ }
581
+ }
582
+ catch (e) {
583
+ this.log(`cmd error: ${e}`);
584
+ await ctx.reply(`Error: ${String(e).slice(0, 200)}`);
585
+ }
586
+ }
587
+ // ---- lifecycle ------------------------------------------------------------
588
+ async run() {
589
+ const tmpDir = path.join(os.tmpdir(), 'codeclaw');
590
+ fs.mkdirSync(tmpDir, { recursive: true });
591
+ this.channel = new TelegramChannel({
592
+ token: this.token,
593
+ workdir: tmpDir,
594
+ allowedChatIds: this.allowedChatIds.size ? this.allowedChatIds : undefined,
595
+ });
596
+ const shutdown = (sig) => {
597
+ this.log(`${sig}, shutting down...`);
598
+ this.channel.disconnect();
599
+ this.stopKeepAlive();
600
+ };
601
+ process.on('SIGINT', () => shutdown('SIGINT'));
602
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
603
+ process.on('SIGUSR2', () => { this.log('SIGUSR2 received, restarting...'); this.performRestart(); });
604
+ const bot = await this.channel.connect();
605
+ this.log(`bot: @${bot.username} (id=${bot.id})`);
606
+ const drained = await this.channel.drain();
607
+ if (drained)
608
+ this.log(`drained ${drained} pending update(s)`);
609
+ // Seed knownChats so setupMenu applies per-chat commands
610
+ for (const cid of this.allowedChatIds)
611
+ this.channel.knownChats.add(cid);
612
+ await this.setupMenu();
613
+ for (const ag of ['claude', 'codex']) {
614
+ this.log(`agent ${ag}: ${whichSync(ag) || 'NOT FOUND'}`);
615
+ }
616
+ this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
617
+ this.channel.onCommand((cmd, args, ctx) => this.handleCommand(cmd, args, ctx));
618
+ this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
619
+ this.channel.onCallback((data, ctx) => this.handleCallback(data, ctx));
620
+ this.channel.onError(err => this.log(`error: ${err}`));
621
+ await this.sendStartupNotice();
622
+ this.startKeepAlive();
623
+ this.log('polling started');
624
+ await this.channel.listen();
625
+ this.stopKeepAlive();
626
+ this.log('stopped');
627
+ }
628
+ async sendStartupNotice() {
629
+ const targets = new Set(this.allowedChatIds);
630
+ for (const cid of this.channel.knownChats)
631
+ targets.add(cid);
632
+ if (!targets.size) {
633
+ this.log('no known chats for startup notice');
634
+ return;
635
+ }
636
+ const agents = ['claude', 'codex'].filter(e => whichSync(e));
637
+ const text = `<b>codeclaw</b> v${VERSION} online\n\n` +
638
+ `<b>Agent:</b> ${escapeHtml(this.defaultAgent)}\n` +
639
+ `<b>Available:</b> ${escapeHtml(agents.join(', ') || 'none')}\n` +
640
+ `<b>Workdir:</b> <code>${escapeHtml(this.workdir)}</code>\n\n` +
641
+ `<i>/help for commands</i>`;
642
+ for (const cid of targets) {
643
+ try {
644
+ await this.channel.send(cid, text, { parseMode: 'HTML' });
645
+ this.log(`startup notice sent to chat=${cid}`);
646
+ }
647
+ catch (e) {
648
+ this.log(`startup notice failed for chat=${cid}: ${e}`);
649
+ }
650
+ }
651
+ }
652
+ }