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.
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Telegram channel — Telegram Bot API comms with Telegram-specific hooks.
3
+ *
4
+ * ┌─ Lifecycle ─────────────────────────────────────────────────────────────┐
5
+ * │ connect() — getMe, 获取 bot 信息 (id, username, displayName) │
6
+ * │ listen() — 启动 long-polling 循环,持续接收更新 │
7
+ * │ disconnect() — 停止 polling,中断进行中的请求 │
8
+ * │ drain() — 跳过所有积压的旧更新,返回跳过数量 │
9
+ * ├─ 发送 (bot → user) ────────────────────────────────────────────────────┤
10
+ * │ send(chatId, text, opts?) — 发送文本,支持 HTML/Markdown、 │
11
+ * │ 回复引用、inline keyboard, │
12
+ * │ 超长自动分片 (4096 上限) │
13
+ * │ editMessage(chatId, msgId, text) — 编辑已发送消息 (流式输出模拟) │
14
+ * │ deleteMessage(chatId, msgId) — 删除消息 │
15
+ * │ sendPhoto(chatId, photo, opts?) — 发送图片 (Buffer),支持 caption │
16
+ * │ sendDocument(chatId, content, filename, opts?) — 发送文件 │
17
+ * │ sendTyping(chatId) — 发送"正在输入"状态 │
18
+ * │ answerCallback(callbackId, text?) — 响应 inline 按钮回调 │
19
+ * ├─ 菜单管理 ─────────────────────────────────────────────────────────────┤
20
+ * │ setMenu(commands) — 注册底部菜单命令 (全局 + knownChats 级别), │
21
+ * │ 同时 setChatMenuButton 让菜单按钮可见 │
22
+ * │ clearMenu() — 删除所有命令,重置菜单按钮为默认 │
23
+ * ├─ 接收 (user → bot) — Hook 注册 ────────────────────────────────────────┤
24
+ * │ onCommand(handler) — /command args,自动解析命令名和参数; │
25
+ * │ 无 handler 时 fallthrough 到 onMessage │
26
+ * │ onMessage(handler) — 聚合消息 { text, files[] }; │
27
+ * │ 图片/文档自动下载到 workdir,提供本地路径 │
28
+ * │ onCallback(handler) — inline keyboard 按钮点击 │
29
+ * │ onError(handler) — polling / handler 错误 │
30
+ * ├─ Handler Context (ctx) ────────────────────────────────────────────────┤
31
+ * │ chatId / messageId / from (id, username, firstName) │
32
+ * │ reply(text, opts) — 直接回复当前消息 │
33
+ * │ editReply(msgId, text, opts) — 编辑之前的消息 │
34
+ * │ answerCallback(text?) — 响应 callback query (仅 callback) │
35
+ * │ channel — channel 实例,可调高级方法 │
36
+ * │ raw — 原始 Telegram update 对象 │
37
+ * ├─ 智能行为 ─────────────────────────────────────────────────────────────┤
38
+ * │ knownChats — 自动记录所有交互过的 chatId,setMenu 自动遍历 │
39
+ * │ 消息聚合 — photo/document 自动下载,统一为 { text, files[] } │
40
+ * │ 群组过滤 — 群聊默认只响应 @mention / 回复 bot 的消息 │
41
+ * │ Chat 白名单 — allowedChatIds 限制只处理特定聊天 │
42
+ * │ 解析失败降级 — HTML 解析失败自动去掉 parseMode 重试 │
43
+ * │ 超长消息分片 — 超过 4096 字符按换行符自动分片发送 │
44
+ * └────────────────────────────────────────────────────────────────────────┘
45
+ *
46
+ * Standalone usage:
47
+ * const ch = new TelegramChannel({ token: 'BOT_TOKEN', workdir: '/tmp' });
48
+ * await ch.connect();
49
+ * ch.onCommand((cmd, args, ctx) => ctx.reply(`Got /${cmd} ${args}`));
50
+ * ch.onMessage((msg, ctx) => ctx.reply(`Echo: ${msg.text} (files: ${msg.files.length})`));
51
+ * await ch.listen();
52
+ */
53
+ import crypto from 'node:crypto';
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+ import { Channel, splitText, sleep } from './channel-base.js';
57
+ export { TelegramChannel };
58
+ const TG_MAX = 4096;
59
+ // ---------------------------------------------------------------------------
60
+ // TelegramChannel
61
+ // ---------------------------------------------------------------------------
62
+ class TelegramChannel extends Channel {
63
+ token;
64
+ base;
65
+ workdir;
66
+ pollTimeout;
67
+ apiTimeout;
68
+ allowedChatIds;
69
+ requireMention;
70
+ offset = 0;
71
+ running = false;
72
+ ac = new AbortController();
73
+ _hCommand = null;
74
+ _hMessage = null;
75
+ _hCallback = null;
76
+ _hError = null;
77
+ /** Chat IDs seen from incoming updates. */
78
+ knownChats = new Set();
79
+ /** Cached menu commands for applying to newly discovered chats. */
80
+ _menuCommands = null;
81
+ constructor(opts) {
82
+ super();
83
+ this.token = opts.token;
84
+ this.base = `https://api.telegram.org/bot${opts.token}`;
85
+ this.workdir = opts.workdir ?? process.cwd();
86
+ this.pollTimeout = opts.pollTimeout ?? 45;
87
+ this.apiTimeout = opts.apiTimeout ?? 60;
88
+ this.allowedChatIds = opts.allowedChatIds ?? new Set();
89
+ this.requireMention = opts.requireMentionInGroup ?? true;
90
+ if (opts.botUsername)
91
+ this.bot = { id: 0, username: opts.botUsername, displayName: '' };
92
+ }
93
+ // ---- Telegram-specific hook registration ----------------------------------
94
+ onCommand(h) { this._hCommand = h; }
95
+ onMessage(h) { this._hMessage = h; }
96
+ onCallback(h) { this._hCallback = h; }
97
+ onError(h) { this._hError = h; }
98
+ // ========================================================================
99
+ // Lifecycle
100
+ // ========================================================================
101
+ async connect() {
102
+ const data = await this.api('getMe');
103
+ const me = data.result;
104
+ this.bot = { id: me.id, username: me.username || '', displayName: me.first_name || '' };
105
+ return this.bot;
106
+ }
107
+ async listen() {
108
+ this.running = true;
109
+ while (this.running) {
110
+ try {
111
+ const data = await this.api('getUpdates', {
112
+ offset: this.offset, timeout: this.pollTimeout,
113
+ allowed_updates: ['message', 'callback_query'],
114
+ });
115
+ for (const update of data.result || []) {
116
+ this.offset = update.update_id + 1;
117
+ this._dispatch(update).catch(e => this._hError?.(e));
118
+ }
119
+ }
120
+ catch (e) {
121
+ if (!this.running || e.name === 'AbortError')
122
+ break;
123
+ this._hError?.(e);
124
+ await sleep(3000);
125
+ }
126
+ }
127
+ }
128
+ disconnect() {
129
+ this.running = false;
130
+ this.ac.abort();
131
+ }
132
+ // ========================================================================
133
+ // Outgoing primitives (Channel interface)
134
+ // ========================================================================
135
+ async send(chatId, text, opts = {}) {
136
+ let msgId = null;
137
+ for (const chunk of splitText(text.trim() || '(empty)', TG_MAX - 200)) {
138
+ const p = { chat_id: chatId, text: chunk, disable_web_page_preview: true };
139
+ if (opts.parseMode)
140
+ p.parse_mode = opts.parseMode;
141
+ if (opts.replyTo != null)
142
+ p.reply_to_message_id = opts.replyTo;
143
+ if (opts.keyboard != null)
144
+ p.reply_markup = opts.keyboard;
145
+ let res;
146
+ try {
147
+ res = await this.api('sendMessage', p);
148
+ }
149
+ catch {
150
+ if (opts.parseMode) {
151
+ delete p.parse_mode;
152
+ res = await this.api('sendMessage', p);
153
+ }
154
+ else
155
+ throw new Error('sendMessage failed');
156
+ }
157
+ msgId ??= res?.result?.message_id ?? null;
158
+ }
159
+ return msgId;
160
+ }
161
+ async editMessage(chatId, msgId, text, opts = {}) {
162
+ if (!text.trim())
163
+ return;
164
+ const t = text.length > 4000 ? text.slice(0, 4000) + '\n...' : text;
165
+ const p = { chat_id: chatId, message_id: msgId, text: t, disable_web_page_preview: true };
166
+ if (opts.parseMode)
167
+ p.parse_mode = opts.parseMode;
168
+ if (opts.keyboard != null)
169
+ p.reply_markup = opts.keyboard;
170
+ try {
171
+ await this.api('editMessageText', p);
172
+ }
173
+ catch (exc) {
174
+ const s = String(exc).toLowerCase();
175
+ if (s.includes('not modified') || s.includes("can't be edited"))
176
+ return;
177
+ if (opts.parseMode && (s.includes("can't parse") || s.includes('bad request'))) {
178
+ delete p.parse_mode;
179
+ try {
180
+ await this.api('editMessageText', p);
181
+ }
182
+ catch { /* ignore */ }
183
+ }
184
+ }
185
+ }
186
+ async deleteMessage(chatId, msgId) {
187
+ try {
188
+ await this.api('deleteMessage', { chat_id: chatId, message_id: msgId });
189
+ }
190
+ catch { /* ignore */ }
191
+ }
192
+ async sendTyping(chatId) {
193
+ await this.api('sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => { });
194
+ }
195
+ // ========================================================================
196
+ // Telegram-specific outgoing
197
+ // ========================================================================
198
+ async answerCallback(callbackId, text) {
199
+ await this.api('answerCallbackQuery', { callback_query_id: callbackId, ...(text ? { text } : {}) }).catch(() => { });
200
+ }
201
+ async sendPhoto(chatId, photo, opts = {}) {
202
+ const hash = crypto.createHash('md5').update(photo).digest('hex').slice(0, 16);
203
+ const boundary = `----codeclaw${hash}`;
204
+ const parts = [];
205
+ const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
206
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
207
+ if (opts.replyTo)
208
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
209
+ if (opts.caption)
210
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
211
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="photo.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`);
212
+ parts.push(photo);
213
+ add(`\r\n--${boundary}--\r\n`);
214
+ try {
215
+ const resp = await fetch(`${this.base}/sendPhoto`, {
216
+ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body: Buffer.concat(parts),
217
+ });
218
+ const data = await resp.json();
219
+ if (!data.ok)
220
+ throw new Error(`Telegram API sendPhoto: ${JSON.stringify(data)}`);
221
+ return data?.result?.message_id ?? null;
222
+ }
223
+ catch (e) {
224
+ throw e;
225
+ }
226
+ }
227
+ async sendDocument(chatId, content, filename, opts = {}) {
228
+ const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
229
+ const hash = crypto.createHash('md5').update(buf).digest('hex').slice(0, 16);
230
+ const boundary = `----codeclaw${hash}`;
231
+ const parts = [];
232
+ const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
233
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
234
+ if (opts.replyTo)
235
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
236
+ if (opts.caption)
237
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
238
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`);
239
+ parts.push(buf);
240
+ add(`\r\n--${boundary}--\r\n`);
241
+ try {
242
+ const resp = await fetch(`${this.base}/sendDocument`, {
243
+ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body: Buffer.concat(parts),
244
+ });
245
+ const data = await resp.json();
246
+ if (!data.ok)
247
+ throw new Error(`Telegram API sendDocument: ${JSON.stringify(data)}`);
248
+ return data?.result?.message_id ?? null;
249
+ }
250
+ catch (e) {
251
+ throw e;
252
+ }
253
+ }
254
+ /** Set bottom menu commands and ensure the menu button is visible.
255
+ * Automatically applies to all known chats (from incoming updates). */
256
+ async setMenu(commands) {
257
+ this._menuCommands = commands;
258
+ await this.api('setMyCommands', { commands });
259
+ await this.api('setChatMenuButton', { menu_button: { type: 'commands' } });
260
+ for (const cid of this.knownChats) {
261
+ await this._applyMenuToChat(cid);
262
+ }
263
+ }
264
+ /** Track a chat ID; apply menu on first discovery. */
265
+ _trackChat(chatId) {
266
+ if (this.knownChats.has(chatId))
267
+ return;
268
+ this.knownChats.add(chatId);
269
+ this._applyMenuToChat(chatId).catch(() => { });
270
+ }
271
+ /** Apply cached menu commands to a single chat. */
272
+ async _applyMenuToChat(chatId) {
273
+ if (!this._menuCommands)
274
+ return;
275
+ await this.api('setMyCommands', {
276
+ commands: this._menuCommands,
277
+ scope: { type: 'chat', chat_id: chatId },
278
+ }).catch(() => { });
279
+ await this.api('setChatMenuButton', {
280
+ chat_id: chatId,
281
+ menu_button: { type: 'commands' },
282
+ }).catch(() => { });
283
+ }
284
+ /** Remove all bot commands and reset menu button to default. */
285
+ async clearMenu() {
286
+ this._menuCommands = null;
287
+ await this.api('deleteMyCommands', {}).catch(() => { });
288
+ await this.api('setChatMenuButton', { menu_button: { type: 'default' } }).catch(() => { });
289
+ for (const cid of this.knownChats) {
290
+ await this.api('deleteMyCommands', { scope: { type: 'chat', chat_id: cid } }).catch(() => { });
291
+ await this.api('setChatMenuButton', { chat_id: cid, menu_button: { type: 'default' } }).catch(() => { });
292
+ }
293
+ }
294
+ /** Drain pending updates (call before listen to skip stale messages). */
295
+ async drain() {
296
+ const data = await this.api('getUpdates', { offset: -1, timeout: 0 });
297
+ const results = data.result || [];
298
+ if (results.length)
299
+ this.offset = results[results.length - 1].update_id + 1;
300
+ return results.length;
301
+ }
302
+ /** Get the chat ID from the most recent incoming message (useful for 1v1 bot setup). */
303
+ async getRecentChatId() {
304
+ const data = await this.api('getUpdates', { offset: -1, timeout: 0 });
305
+ const results = data.result || [];
306
+ if (!results.length)
307
+ return null;
308
+ const u = results[results.length - 1];
309
+ return u.message?.chat?.id ?? u.callback_query?.message?.chat?.id ?? null;
310
+ }
311
+ /** Download a Telegram file to a local path. Returns the local path. */
312
+ async downloadFile(fileId, destFilename) {
313
+ const meta = await this.api('getFile', { file_id: fileId });
314
+ const filePath = meta.result.file_path;
315
+ const url = `https://api.telegram.org/file/bot${this.token}/${filePath}`;
316
+ const resp = await fetch(url);
317
+ const buf = Buffer.from(await resp.arrayBuffer());
318
+ const ext = path.extname(filePath) || '.bin';
319
+ const name = destFilename || `tg_${fileId.slice(-8)}${ext}`;
320
+ const localPath = path.join(this.workdir, name);
321
+ fs.writeFileSync(localPath, buf);
322
+ return localPath;
323
+ }
324
+ // ========================================================================
325
+ // Low-level API
326
+ // ========================================================================
327
+ async api(method, payload) {
328
+ const timeout = method === 'getUpdates' ? (this.pollTimeout + 10) * 1000 : this.apiTimeout * 1000;
329
+ const signal = this.running
330
+ ? AbortSignal.any([AbortSignal.timeout(timeout), this.ac.signal])
331
+ : AbortSignal.timeout(timeout);
332
+ const resp = await fetch(`${this.base}/${method}`, {
333
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
334
+ body: JSON.stringify(payload ?? {}), signal,
335
+ });
336
+ const data = await resp.json();
337
+ if (!data.ok)
338
+ throw new Error(`Telegram API ${method}: ${JSON.stringify(data)}`);
339
+ return data;
340
+ }
341
+ // ========================================================================
342
+ // Internal: dispatch
343
+ // ========================================================================
344
+ async _dispatch(update) {
345
+ // callback query
346
+ if (update.callback_query) {
347
+ const cq = update.callback_query;
348
+ const chatId = cq.message?.chat?.id;
349
+ this._log(`[recv] callback_query id=${cq.id} chat=${chatId} from=${cq.from?.username || cq.from?.id} data="${cq.data}"`);
350
+ if (!chatId || !this._isAllowed(chatId)) {
351
+ this._log(`[recv] callback blocked: chat=${chatId} not allowed`);
352
+ return;
353
+ }
354
+ this._trackChat(chatId);
355
+ if (!this._hCallback)
356
+ return;
357
+ const ctx = this._makeCtx(chatId, cq.message?.message_id ?? 0, cq.from, cq);
358
+ ctx.callbackId = cq.id;
359
+ ctx.answerCallback = (text) => this.answerCallback(cq.id, text);
360
+ await this._hCallback(cq.data || '', ctx);
361
+ return;
362
+ }
363
+ // message
364
+ const raw = update.message || update.edited_message;
365
+ if (!raw || !raw.chat?.id)
366
+ return;
367
+ const chatId = raw.chat.id;
368
+ const fromUser = raw.from?.username || raw.from?.first_name || raw.from?.id || '?';
369
+ const msgPreview = (raw.text || raw.caption || '').slice(0, 120);
370
+ this._log(`[recv] message chat=${chatId} from=${fromUser} msg_id=${raw.message_id} text="${msgPreview}"${raw.photo ? ' +photo' : ''}${raw.document ? ` +doc(${raw.document?.file_name})` : ''}`);
371
+ if (!this._isAllowed(chatId)) {
372
+ this._log(`[recv] blocked: chat=${chatId} not in allowlist`);
373
+ return;
374
+ }
375
+ this._trackChat(chatId);
376
+ if (!this._shouldHandle(raw)) {
377
+ this._log(`[recv] skipped: not relevant (group mention/reply check)`);
378
+ return;
379
+ }
380
+ const from = { id: raw.from?.id, username: raw.from?.username, firstName: raw.from?.first_name };
381
+ const ctx = this._makeCtx(chatId, raw.message_id, from, raw);
382
+ // command — if no command handler registered, fall through to message handler
383
+ const entities = raw.entities || [];
384
+ const cmdEntity = entities.find((e) => e.type === 'bot_command' && e.offset === 0);
385
+ if (cmdEntity) {
386
+ const full = (raw.text || '').slice(cmdEntity.offset, cmdEntity.offset + cmdEntity.length);
387
+ const cmd = full.replace(/^\//, '').split('@')[0].toLowerCase();
388
+ const args = (raw.text || '').slice(cmdEntity.offset + cmdEntity.length).trim();
389
+ this._log(`[recv] command /${cmd} args="${args.slice(0, 80)}" chat=${chatId}`);
390
+ if (this._hCommand) {
391
+ await this._hCommand(cmd, args, ctx);
392
+ return;
393
+ }
394
+ }
395
+ // message (text + files aggregation)
396
+ if (!this._hMessage)
397
+ return;
398
+ const text = this._cleanMention(raw.text || raw.caption || '');
399
+ const files = [];
400
+ // download photo
401
+ if (raw.photo?.length) {
402
+ const best = raw.photo[raw.photo.length - 1];
403
+ this._log(`[recv] downloading photo file_id=${best.file_id} size=${best.width}x${best.height}`);
404
+ try {
405
+ const localPath = await this.downloadFile(best.file_id, `_tg_photo_${raw.message_id}.jpg`);
406
+ files.push(localPath);
407
+ this._log(`[recv] photo saved: ${localPath}`);
408
+ }
409
+ catch (e) {
410
+ this._log(`[recv] photo download failed: ${e}`);
411
+ this._hError?.(e);
412
+ }
413
+ }
414
+ // download document
415
+ if (raw.document) {
416
+ const origName = raw.document.file_name || `doc_${raw.message_id}`;
417
+ this._log(`[recv] downloading document "${origName}" file_id=${raw.document.file_id}`);
418
+ try {
419
+ const localPath = await this.downloadFile(raw.document.file_id, `_tg_${origName}`);
420
+ files.push(localPath);
421
+ this._log(`[recv] document saved: ${localPath}`);
422
+ }
423
+ catch (e) {
424
+ this._log(`[recv] document download failed: ${e}`);
425
+ this._hError?.(e);
426
+ }
427
+ }
428
+ this._log(`[dispatch] -> onMessage text="${text.slice(0, 80)}" files=${files.length} chat=${chatId}`);
429
+ await this._hMessage({ text, files }, ctx);
430
+ }
431
+ // ========================================================================
432
+ // Internal: helpers
433
+ // ========================================================================
434
+ _makeCtx(chatId, messageId, from, raw) {
435
+ return {
436
+ chatId, messageId,
437
+ from: { id: from?.id, username: from?.username, firstName: from?.first_name },
438
+ reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId }),
439
+ editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
440
+ answerCallback: () => Promise.resolve(),
441
+ channel: this,
442
+ raw,
443
+ };
444
+ }
445
+ _isAllowed(chatId) {
446
+ return this.allowedChatIds.size === 0 || this.allowedChatIds.has(chatId);
447
+ }
448
+ _shouldHandle(raw) {
449
+ const chatType = raw.chat?.type || '';
450
+ const text = (raw.text || raw.caption || '').trim();
451
+ const hasMedia = !!raw.photo || !!raw.document;
452
+ if (chatType === 'private')
453
+ return !!(text || hasMedia);
454
+ if ((raw.entities || []).some((e) => e.type === 'bot_command' && e.offset === 0))
455
+ return true;
456
+ if (!this.requireMention)
457
+ return !!(text || hasMedia);
458
+ const mention = this.bot?.username ? `@${this.bot.username.toLowerCase()}` : '';
459
+ if (mention && text.toLowerCase().includes(mention))
460
+ return true;
461
+ if (raw.reply_to_message?.from?.id === (this.bot?.id ?? 0))
462
+ return true;
463
+ return false;
464
+ }
465
+ _cleanMention(text) {
466
+ if (this.bot?.username)
467
+ text = text.replace(new RegExp(`@${this.bot.username}`, 'gi'), '');
468
+ return text.trim();
469
+ }
470
+ _log(msg) {
471
+ const ts = new Date().toTimeString().slice(0, 8);
472
+ process.stdout.write(`[telegram ${ts}] ${msg}\n`);
473
+ }
474
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli.ts — CLI entry point for codeclaw.
4
+ */
5
+ import { VERSION, envBool } from './bot.js';
6
+ import { TelegramBot } from './bot-telegram.js';
7
+ const VALID_CHANNELS = new Set(['telegram', 'feishu', 'whatsapp']);
8
+ function parseArgs(argv) {
9
+ const args = {
10
+ channel: null, token: null, agent: null, model: null, workdir: null,
11
+ fullAccess: null, safeMode: false, allowedIds: null,
12
+ timeout: null, version: false, help: false,
13
+ };
14
+ const it = argv[Symbol.iterator]();
15
+ for (const arg of it) {
16
+ switch (arg) {
17
+ case '-c':
18
+ case '--channel':
19
+ args.channel = it.next().value;
20
+ break;
21
+ case '-t':
22
+ case '--token':
23
+ args.token = it.next().value;
24
+ break;
25
+ case '-a':
26
+ case '--agent':
27
+ args.agent = it.next().value;
28
+ break;
29
+ case '-m':
30
+ case '--model':
31
+ args.model = it.next().value;
32
+ break;
33
+ case '-w':
34
+ case '--workdir':
35
+ args.workdir = it.next().value;
36
+ break;
37
+ case '--full-access':
38
+ args.fullAccess = true;
39
+ break;
40
+ case '--safe-mode':
41
+ args.safeMode = true;
42
+ break;
43
+ case '--allowed-ids':
44
+ args.allowedIds = it.next().value;
45
+ break;
46
+ case '--timeout':
47
+ args.timeout = parseInt(it.next().value ?? '', 10);
48
+ break;
49
+ case '-v':
50
+ case '--version':
51
+ args.version = true;
52
+ break;
53
+ case '-h':
54
+ case '--help':
55
+ args.help = true;
56
+ break;
57
+ default:
58
+ if (arg.startsWith('-')) {
59
+ process.stderr.write(`Unknown option: ${arg}\n`);
60
+ process.exit(1);
61
+ }
62
+ }
63
+ }
64
+ return args;
65
+ }
66
+ export async function main() {
67
+ const args = parseArgs(process.argv.slice(2));
68
+ if (args.version) {
69
+ process.stdout.write(`codeclaw ${VERSION}\n`);
70
+ process.exit(0);
71
+ }
72
+ const noToken = !args.token && !process.env.CODECLAW_TOKEN
73
+ && !process.env.TELEGRAM_BOT_TOKEN
74
+ && !process.env.FEISHU_APP_ID
75
+ && !process.env.WHATSAPP_TOKEN;
76
+ if (args.help || noToken) {
77
+ process.stdout.write(`codeclaw v${VERSION} — Bridge AI coding agents to your IM.
78
+
79
+ Run a bot that forwards messages to a local AI coding agent (Claude, Codex,
80
+ Gemini), streams responses in real-time, and manages sessions and workdirs.
81
+
82
+ Usage:
83
+ npx codeclaw -c telegram -t <BOT_TOKEN>
84
+ npx codeclaw -c telegram -t <BOT_TOKEN> -a codex
85
+ npx codeclaw -c telegram -t <BOT_TOKEN> -w ~/project
86
+ npx codeclaw -c feishu -t <APP_ID>:<APP_SECRET>
87
+ npx codeclaw -c whatsapp -t <TOKEN>
88
+ CODECLAW_TOKEN=<TOKEN> npx codeclaw
89
+
90
+ Options:
91
+ -c, --channel <channel> IM channel: telegram | feishu | whatsapp [default: telegram]
92
+ -t, --token <token> Channel auth token (env: CODECLAW_TOKEN)
93
+ -a, --agent <agent> AI agent: claude | codex | gemini [default: claude]
94
+ -m, --model <model> Default model, switchable in chat via /agents
95
+ -w, --workdir <dir> Working directory for the agent [default: cwd]
96
+ --full-access Skip confirmation prompts [default]
97
+ --safe-mode Require confirmation before destructive actions
98
+ --allowed-ids <id,id> Comma-separated chat/user ID whitelist
99
+ --timeout <seconds> Max seconds per agent request [default: 300]
100
+ -v, --version Print version
101
+ -h, --help Print this help
102
+
103
+ Environment variables (general):
104
+ CODECLAW_TOKEN Channel auth token (same as -t, channel-agnostic)
105
+ DEFAULT_AGENT Default agent (same as -a)
106
+ CODECLAW_WORKDIR Working directory (same as -w)
107
+ CODECLAW_TIMEOUT Timeout in seconds (same as --timeout)
108
+
109
+ Environment variables (per channel):
110
+ TELEGRAM_BOT_TOKEN Telegram bot token (from @BotFather)
111
+ TELEGRAM_ALLOWED_CHAT_IDS Comma-separated allowed Telegram chat IDs
112
+ FEISHU_APP_ID Feishu/Lark app ID
113
+ FEISHU_APP_SECRET Feishu/Lark app secret
114
+ WHATSAPP_TOKEN WhatsApp Business API token
115
+ WHATSAPP_PHONE_ID WhatsApp phone number ID
116
+
117
+ Environment variables (per agent):
118
+ CLAUDE_MODEL Claude model name
119
+ CLAUDE_PERMISSION_MODE Permission mode (default: bypassPermissions)
120
+ CLAUDE_EXTRA_ARGS Extra CLI args for claude
121
+ CODEX_MODEL Codex model name
122
+ CODEX_REASONING_EFFORT Reasoning effort (default: xhigh)
123
+ CODEX_FULL_ACCESS Full-access mode (default: true)
124
+ CODEX_EXTRA_ARGS Extra CLI args for codex
125
+ GEMINI_MODEL Gemini model name
126
+ GEMINI_EXTRA_ARGS Extra CLI args for gemini
127
+
128
+ Bot commands (available once running):
129
+ /sessions List or switch coding sessions
130
+ /agents List or switch AI agents
131
+ /status Bot status, uptime, and token usage
132
+ /host Host machine info (CPU, memory, disk)
133
+ /switch Browse and change working directory
134
+ /restart Restart with latest version
135
+
136
+ Prerequisites: Node.js >= 18, and at least one agent CLI installed (claude, codex, or gemini).
137
+ Docs: https://github.com/xiaotonng/codeclaw
138
+ `);
139
+ process.exit(0);
140
+ }
141
+ // resolve channel
142
+ const channel = (args.channel || process.env.CODECLAW_CHANNEL || 'telegram').trim().toLowerCase();
143
+ if (!VALID_CHANNELS.has(channel)) {
144
+ process.stderr.write(`Unknown channel: ${channel}. Available: ${[...VALID_CHANNELS].join(', ')}\n`);
145
+ process.exit(1);
146
+ }
147
+ // map CLI flags to env (channel-agnostic → channel-specific)
148
+ if (args.token) {
149
+ if (channel === 'telegram')
150
+ process.env.TELEGRAM_BOT_TOKEN = args.token;
151
+ else if (channel === 'feishu') {
152
+ const [appId, ...rest] = args.token.split(':');
153
+ process.env.FEISHU_APP_ID = appId;
154
+ if (rest.length)
155
+ process.env.FEISHU_APP_SECRET = rest.join(':');
156
+ }
157
+ else if (channel === 'whatsapp')
158
+ process.env.WHATSAPP_TOKEN = args.token;
159
+ }
160
+ // fallback: CODECLAW_TOKEN → channel-specific env
161
+ if (!args.token && process.env.CODECLAW_TOKEN) {
162
+ if (channel === 'telegram' && !process.env.TELEGRAM_BOT_TOKEN)
163
+ process.env.TELEGRAM_BOT_TOKEN = process.env.CODECLAW_TOKEN;
164
+ else if (channel === 'whatsapp' && !process.env.WHATSAPP_TOKEN)
165
+ process.env.WHATSAPP_TOKEN = process.env.CODECLAW_TOKEN;
166
+ }
167
+ if (args.agent)
168
+ process.env.DEFAULT_AGENT = args.agent;
169
+ if (args.workdir)
170
+ process.env.CODECLAW_WORKDIR = args.workdir;
171
+ if (args.model) {
172
+ const ag = args.agent || process.env.DEFAULT_AGENT || 'claude';
173
+ if (ag === 'codex')
174
+ process.env.CODEX_MODEL = args.model;
175
+ else if (ag === 'gemini')
176
+ process.env.GEMINI_MODEL = args.model;
177
+ else
178
+ process.env.CLAUDE_MODEL = args.model;
179
+ }
180
+ if (args.allowedIds) {
181
+ if (channel === 'telegram')
182
+ process.env.TELEGRAM_ALLOWED_CHAT_IDS = args.allowedIds;
183
+ }
184
+ if (args.timeout != null)
185
+ process.env.CODECLAW_TIMEOUT = String(args.timeout);
186
+ if (args.safeMode) {
187
+ process.env.CODEX_FULL_ACCESS = 'false';
188
+ process.env.CLAUDE_PERMISSION_MODE = 'default';
189
+ }
190
+ else if (args.fullAccess || envBool('CODECLAW_FULL_ACCESS', true)) {
191
+ process.env.CODEX_FULL_ACCESS = 'true';
192
+ process.env.CLAUDE_PERMISSION_MODE = 'bypassPermissions';
193
+ }
194
+ // dispatch to channel-specific bot
195
+ switch (channel) {
196
+ case 'telegram':
197
+ await new TelegramBot().run();
198
+ break;
199
+ case 'feishu':
200
+ process.stderr.write('Feishu channel is not yet implemented. Coming soon.\n');
201
+ process.exit(1);
202
+ break;
203
+ case 'whatsapp':
204
+ process.stderr.write('WhatsApp channel is not yet implemented. Coming soon.\n');
205
+ process.exit(1);
206
+ break;
207
+ }
208
+ }
209
+ main().catch(err => { console.error(err); process.exit(1); });