pikiclaw 0.2.35

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,773 @@
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 { Readable } from 'node:stream';
57
+ import { pipeline } from 'node:stream/promises';
58
+ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
59
+ import { Channel, DEFAULT_CHANNEL_CAPABILITIES, splitText, sleep, } from './channel-base.js';
60
+ // ---------------------------------------------------------------------------
61
+ // Proxy support — automatically respects HTTPS_PROXY / HTTP_PROXY / NO_PROXY
62
+ // ---------------------------------------------------------------------------
63
+ setGlobalDispatcher(new EnvHttpProxyAgent());
64
+ export { TelegramChannel };
65
+ const TG_MAX = 4096;
66
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
67
+ function previewText(value, max = 280) {
68
+ const normalized = value.replace(/\s+/g, ' ').trim();
69
+ if (!normalized)
70
+ return '(empty)';
71
+ return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
72
+ }
73
+ function addErrorMetadata(parts, err) {
74
+ for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname', 'path']) {
75
+ const value = err?.[key];
76
+ if (value != null && value !== '')
77
+ parts.push(`${key}=${value}`);
78
+ }
79
+ }
80
+ function describeError(err) {
81
+ if (!(err instanceof Error))
82
+ return String(err ?? 'unknown error');
83
+ const parts = [`${err.name}: ${err.message}`];
84
+ addErrorMetadata(parts, err);
85
+ if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length) {
86
+ parts.push(`errors=[${err.errors.slice(0, 3).map(item => describeError(item)).join(' | ')}]`);
87
+ }
88
+ const cause = err.cause;
89
+ if (cause && cause !== err) {
90
+ parts.push(`cause=${describeError(cause)}`);
91
+ }
92
+ return parts.join(' | ');
93
+ }
94
+ function isRetryableRequestError(err) {
95
+ const text = describeError(err).toLowerCase();
96
+ return [
97
+ 'fetch failed',
98
+ 'econnreset',
99
+ 'etimedout',
100
+ 'enotfound',
101
+ 'eai_again',
102
+ 'econnrefused',
103
+ 'socket hang up',
104
+ 'http 502',
105
+ 'http 503',
106
+ 'http 504',
107
+ 'bad gateway',
108
+ 'service unavailable',
109
+ 'gateway timeout',
110
+ ].some(token => text.includes(token));
111
+ }
112
+ function isParseModeError(err) {
113
+ const text = describeError(err).toLowerCase();
114
+ return text.includes("can't parse")
115
+ || text.includes('parse entities')
116
+ || text.includes('unsupported start tag')
117
+ || text.includes('unsupported tag');
118
+ }
119
+ function wrapSendError(err) {
120
+ return new Error(`sendMessage failed: ${describeError(err)}`, {
121
+ cause: err instanceof Error ? err : undefined,
122
+ });
123
+ }
124
+ function isAbortError(err) {
125
+ if (err instanceof Error && err.name === 'AbortError')
126
+ return true;
127
+ const cause = err?.cause;
128
+ if (cause && cause !== err)
129
+ return isAbortError(cause);
130
+ return false;
131
+ }
132
+ async function parseJsonResponse(resp, label) {
133
+ const raw = await resp.text();
134
+ const bodyPreview = previewText(raw);
135
+ let data = null;
136
+ if (raw) {
137
+ try {
138
+ data = JSON.parse(raw);
139
+ }
140
+ catch (err) {
141
+ throw new Error(`${label} returned invalid JSON: HTTP ${resp.status} ${resp.statusText || ''}`.trim() +
142
+ `; body=${bodyPreview}; parse=${describeError(err)}`);
143
+ }
144
+ }
145
+ if (!resp.ok) {
146
+ const detail = data != null ? previewText(JSON.stringify(data)) : bodyPreview;
147
+ throw new Error(`${label} failed: HTTP ${resp.status} ${resp.statusText || ''}`.trim() + `; body=${detail}`);
148
+ }
149
+ return data;
150
+ }
151
+ function mimeTypeForFilename(filename) {
152
+ switch (path.extname(filename).toLowerCase()) {
153
+ case '.png': return 'image/png';
154
+ case '.webp': return 'image/webp';
155
+ case '.jpg':
156
+ case '.jpeg':
157
+ default:
158
+ return 'image/jpeg';
159
+ }
160
+ }
161
+ function isPollingConflictError(err) {
162
+ const msg = err instanceof Error ? err.message : String(err ?? '');
163
+ return msg.startsWith('Telegram polling conflict:');
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // TelegramChannel
167
+ // ---------------------------------------------------------------------------
168
+ class TelegramChannel extends Channel {
169
+ capabilities = {
170
+ ...DEFAULT_CHANNEL_CAPABILITIES,
171
+ editMessages: true,
172
+ typingIndicators: true,
173
+ commandMenu: true,
174
+ callbackActions: true,
175
+ messageReactions: true,
176
+ fileUpload: true,
177
+ fileDownload: true,
178
+ threads: true,
179
+ };
180
+ token;
181
+ base;
182
+ workdir;
183
+ pollTimeout;
184
+ apiTimeout;
185
+ allowedChatIds;
186
+ requireMention;
187
+ offset = 0;
188
+ skipPendingOnNextListen = false;
189
+ running = false;
190
+ ac = new AbortController();
191
+ messageChains = new Map();
192
+ _hCommand = null;
193
+ _hMessage = null;
194
+ _hCallback = null;
195
+ _hError = null;
196
+ /** Chat IDs seen from incoming updates. */
197
+ knownChats = new Set();
198
+ /** Cached menu commands for applying to newly discovered chats. */
199
+ _menuCommands = null;
200
+ constructor(opts) {
201
+ super();
202
+ this.token = opts.token;
203
+ this.base = `https://api.telegram.org/bot${opts.token}`;
204
+ this.workdir = opts.workdir ?? process.cwd();
205
+ this.pollTimeout = opts.pollTimeout ?? 45;
206
+ this.apiTimeout = opts.apiTimeout ?? 60;
207
+ this.allowedChatIds = opts.allowedChatIds ?? new Set();
208
+ this.requireMention = opts.requireMentionInGroup ?? true;
209
+ if (opts.botUsername)
210
+ this.bot = { id: 0, username: opts.botUsername, displayName: '' };
211
+ }
212
+ // ---- Telegram-specific hook registration ----------------------------------
213
+ onCommand(h) { this._hCommand = h; }
214
+ onMessage(h) { this._hMessage = h; }
215
+ onCallback(h) { this._hCallback = h; }
216
+ onError(h) { this._hError = h; }
217
+ // ========================================================================
218
+ // Lifecycle
219
+ // ========================================================================
220
+ async connect() {
221
+ let delay = 2000;
222
+ for (let attempt = 1;; attempt++) {
223
+ try {
224
+ const data = await this.api('getMe');
225
+ const me = data.result;
226
+ this.bot = { id: me.id, username: me.username || '', displayName: me.first_name || '' };
227
+ return this.bot;
228
+ }
229
+ catch (e) {
230
+ if (this.ac.signal.aborted || isAbortError(e))
231
+ throw e;
232
+ if (attempt >= 10)
233
+ throw e;
234
+ this._log(`[connect] attempt ${attempt} failed: ${e.message ?? e} — retrying in ${delay / 1000}s`);
235
+ await sleep(delay);
236
+ delay = Math.min(delay * 2, 60_000);
237
+ }
238
+ }
239
+ }
240
+ async listen() {
241
+ this.running = true;
242
+ let backoff = 3000;
243
+ while (this.running) {
244
+ try {
245
+ const requestOffset = this.skipPendingOnNextListen ? -1 : this.offset;
246
+ const data = await this.api('getUpdates', {
247
+ offset: requestOffset, timeout: this.pollTimeout,
248
+ allowed_updates: ['message', 'callback_query'],
249
+ });
250
+ const skippedPending = this.skipPendingOnNextListen;
251
+ if (skippedPending)
252
+ this.skipPendingOnNextListen = false;
253
+ backoff = 3000; // reset on success
254
+ const results = data.result || [];
255
+ if (skippedPending && !results.length)
256
+ this.offset = 0;
257
+ for (const update of results) {
258
+ this.offset = update.update_id + 1;
259
+ this._dispatch(update).catch(e => this._hError?.(e));
260
+ }
261
+ }
262
+ catch (e) {
263
+ if (!this.running || this.ac.signal.aborted || isAbortError(e))
264
+ break;
265
+ if (isPollingConflictError(e)) {
266
+ const err = e instanceof Error ? e : new Error(String(e));
267
+ this.running = false;
268
+ this._log(`[poll] conflict: ${err.message} — stopping`);
269
+ this._hError?.(err);
270
+ break;
271
+ }
272
+ this._log(`[poll] error: ${e.message ?? e} — retrying in ${backoff / 1000}s`);
273
+ this._hError?.(e);
274
+ await sleep(backoff);
275
+ backoff = Math.min(backoff * 2, 60_000);
276
+ }
277
+ }
278
+ }
279
+ disconnect() {
280
+ this.running = false;
281
+ this.ac.abort();
282
+ }
283
+ skipPendingUpdatesOnNextListen() {
284
+ this.skipPendingOnNextListen = true;
285
+ }
286
+ _logOutgoingText(action, meta, text) {
287
+ const ts = new Date().toTimeString().slice(0, 8);
288
+ process.stdout.write(`[telegram ${ts}] [send] ${action} ${meta}\n${text}\n`);
289
+ }
290
+ _logOutgoingPreview(action, meta, text) {
291
+ this._log(`[send] ${action} ${meta} chars=${text.length}`);
292
+ }
293
+ _logOutgoingFile(action, meta) {
294
+ this._log(`[send] ${action} ${meta}`);
295
+ }
296
+ _requestSignal(timeoutMs) {
297
+ return AbortSignal.any([AbortSignal.timeout(timeoutMs), this.ac.signal]);
298
+ }
299
+ async _fetchResponse(label, url, init, timeoutMs) {
300
+ try {
301
+ return await fetch(url, { ...init, signal: this._requestSignal(timeoutMs) });
302
+ }
303
+ catch (err) {
304
+ throw new Error(`${label} request failed after ${Math.ceil(timeoutMs / 1000)}s: ${describeError(err)}`, {
305
+ cause: err instanceof Error ? err : undefined,
306
+ });
307
+ }
308
+ }
309
+ // ========================================================================
310
+ // Outgoing primitives (Channel interface)
311
+ // ========================================================================
312
+ _applyThreadId(payload, opts) {
313
+ if (opts?.messageThreadId != null)
314
+ payload.message_thread_id = opts.messageThreadId;
315
+ }
316
+ async send(chatId, text, opts = {}) {
317
+ let msgId = null;
318
+ const chunks = splitText(text.trim() || '(empty)', TG_MAX - 200);
319
+ for (let index = 0; index < chunks.length; index++) {
320
+ const chunk = chunks[index];
321
+ const p = { chat_id: chatId, text: chunk, disable_web_page_preview: true };
322
+ if (opts.parseMode)
323
+ p.parse_mode = opts.parseMode;
324
+ if (opts.replyTo != null)
325
+ p.reply_to_message_id = opts.replyTo;
326
+ if (opts.keyboard != null)
327
+ p.reply_markup = opts.keyboard;
328
+ this._applyThreadId(p, opts);
329
+ this._logOutgoingText('sendMessage', `chat=${chatId} chunk=${index + 1}/${chunks.length}${opts.replyTo != null ? ` reply_to=${opts.replyTo}` : ''}${opts.parseMode ? ` parse=${opts.parseMode}` : ''}`, chunk);
330
+ let res;
331
+ let lastErr = null;
332
+ for (let attempt = 1; attempt <= 3; attempt++) {
333
+ try {
334
+ res = await this.api('sendMessage', p);
335
+ lastErr = null;
336
+ break;
337
+ }
338
+ catch (err) {
339
+ lastErr = err;
340
+ if (attempt >= 3 || !isRetryableRequestError(err))
341
+ break;
342
+ const delayMs = attempt * 250;
343
+ this._log(`[send] sendMessage transient error attempt=${attempt} chat=${chatId}: ${describeError(err)} — retrying in ${delayMs}ms`);
344
+ await sleep(delayMs);
345
+ }
346
+ }
347
+ if (lastErr) {
348
+ if (opts.parseMode && isParseModeError(lastErr)) {
349
+ delete p.parse_mode;
350
+ try {
351
+ res = await this.api('sendMessage', p);
352
+ }
353
+ catch (err) {
354
+ throw wrapSendError(err);
355
+ }
356
+ }
357
+ else {
358
+ throw wrapSendError(lastErr);
359
+ }
360
+ }
361
+ msgId ??= res?.result?.message_id ?? null;
362
+ }
363
+ return msgId;
364
+ }
365
+ async editMessage(chatId, msgId, text, opts = {}) {
366
+ if (!text.trim())
367
+ return;
368
+ const t = text.length > 4000 ? text.slice(0, 4000) + '\n...' : text;
369
+ const p = { chat_id: chatId, message_id: msgId, text: t, disable_web_page_preview: true };
370
+ if (opts.parseMode)
371
+ p.parse_mode = opts.parseMode;
372
+ if (opts.keyboard != null)
373
+ p.reply_markup = opts.keyboard;
374
+ this._logOutgoingPreview('editMessageText', `chat=${chatId} msg_id=${msgId}${opts.parseMode ? ` parse=${opts.parseMode}` : ''}`, t);
375
+ try {
376
+ await this.api('editMessageText', p);
377
+ }
378
+ catch (exc) {
379
+ const s = String(exc).toLowerCase();
380
+ if (s.includes('not modified') || s.includes("can't be edited"))
381
+ return;
382
+ if (opts.parseMode && (s.includes("can't parse") || s.includes('bad request'))) {
383
+ delete p.parse_mode;
384
+ try {
385
+ await this.api('editMessageText', p);
386
+ }
387
+ catch { /* ignore */ }
388
+ }
389
+ }
390
+ }
391
+ async sendMessageDraft(chatId, draftId, text, opts = {}) {
392
+ if (!text.trim())
393
+ return;
394
+ const t = text.length > TG_MAX ? text.slice(0, TG_MAX) : text;
395
+ const p = { chat_id: chatId, draft_id: draftId, text: t };
396
+ if (opts.parseMode)
397
+ p.parse_mode = opts.parseMode;
398
+ this._applyThreadId(p, opts);
399
+ this._logOutgoingPreview('sendMessageDraft', `chat=${chatId} draft_id=${draftId}${opts.messageThreadId != null ? ` thread=${opts.messageThreadId}` : ''}${opts.parseMode ? ` parse=${opts.parseMode}` : ''}`, t);
400
+ await this.api('sendMessageDraft', p);
401
+ }
402
+ async deleteMessage(chatId, msgId) {
403
+ try {
404
+ await this.api('deleteMessage', { chat_id: chatId, message_id: msgId });
405
+ }
406
+ catch { /* ignore */ }
407
+ }
408
+ async sendTyping(chatId, opts = {}) {
409
+ const payload = { chat_id: chatId, action: 'typing' };
410
+ this._applyThreadId(payload, opts);
411
+ await this.api('sendChatAction', payload).catch(() => { });
412
+ }
413
+ // ========================================================================
414
+ // Telegram-specific outgoing
415
+ // ========================================================================
416
+ async answerCallback(callbackId, text) {
417
+ if (text)
418
+ this._logOutgoingText('answerCallbackQuery', `callback_id=${callbackId}`, text);
419
+ await this.api('answerCallbackQuery', { callback_query_id: callbackId, ...(text ? { text } : {}) }).catch(() => { });
420
+ }
421
+ async setMessageReaction(chatId, msgId, reactions) {
422
+ const payload = {
423
+ chat_id: chatId,
424
+ message_id: msgId,
425
+ reaction: reactions.map(emoji => ({ type: 'emoji', emoji })),
426
+ is_big: false,
427
+ };
428
+ await this.api('setMessageReaction', payload).catch(() => { });
429
+ }
430
+ async sendPhoto(chatId, photo, opts = {}) {
431
+ const hash = crypto.createHash('md5').update(photo).digest('hex').slice(0, 16);
432
+ const boundary = `----pikiclaw${hash}`;
433
+ const parts = [];
434
+ const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
435
+ const filename = opts.filename || 'photo.jpg';
436
+ const mimeType = opts.mimeType || mimeTypeForFilename(filename);
437
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
438
+ if (opts.replyTo != null)
439
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
440
+ if (opts.messageThreadId != null)
441
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${opts.messageThreadId}\r\n`);
442
+ if (opts.caption)
443
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
444
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
445
+ parts.push(photo);
446
+ add(`\r\n--${boundary}--\r\n`);
447
+ this._logOutgoingFile('sendPhoto', `chat=${chatId} file=${filename} bytes=${photo.byteLength}${opts.replyTo != null ? ` reply_to=${opts.replyTo}` : ''}`);
448
+ if (opts.caption)
449
+ this._logOutgoingText('sendPhoto.caption', `chat=${chatId} file=${filename}`, opts.caption.slice(0, 1024));
450
+ const resp = await this._fetchResponse('Telegram API sendPhoto', `${this.base}/sendPhoto`, {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
453
+ body: Buffer.concat(parts),
454
+ }, this.apiTimeout * 1000);
455
+ const data = await parseJsonResponse(resp, 'Telegram API sendPhoto');
456
+ if (!data?.ok)
457
+ throw new Error(`Telegram API sendPhoto: ${previewText(JSON.stringify(data))}`);
458
+ return data?.result?.message_id ?? null;
459
+ }
460
+ async sendDocument(chatId, content, filename, opts = {}) {
461
+ const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
462
+ const hash = crypto.createHash('md5').update(buf).digest('hex').slice(0, 16);
463
+ const boundary = `----pikiclaw${hash}`;
464
+ const parts = [];
465
+ const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
466
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
467
+ if (opts.replyTo)
468
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
469
+ if (opts.messageThreadId != null)
470
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${opts.messageThreadId}\r\n`);
471
+ if (opts.caption)
472
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
473
+ add(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`);
474
+ parts.push(buf);
475
+ add(`\r\n--${boundary}--\r\n`);
476
+ this._logOutgoingFile('sendDocument', `chat=${chatId} file=${filename} bytes=${buf.byteLength}${opts.replyTo != null ? ` reply_to=${opts.replyTo}` : ''}`);
477
+ if (opts.caption)
478
+ this._logOutgoingText('sendDocument.caption', `chat=${chatId} file=${filename}`, opts.caption.slice(0, 1024));
479
+ if (typeof content === 'string')
480
+ this._logOutgoingText('sendDocument.body', `chat=${chatId} file=${filename}`, content);
481
+ const resp = await this._fetchResponse('Telegram API sendDocument', `${this.base}/sendDocument`, {
482
+ method: 'POST',
483
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
484
+ body: Buffer.concat(parts),
485
+ }, this.apiTimeout * 1000);
486
+ const data = await parseJsonResponse(resp, 'Telegram API sendDocument');
487
+ if (!data?.ok)
488
+ throw new Error(`Telegram API sendDocument: ${previewText(JSON.stringify(data))}`);
489
+ return data?.result?.message_id ?? null;
490
+ }
491
+ async sendFile(chatId, filePath, opts = {}) {
492
+ const content = fs.readFileSync(filePath);
493
+ const filename = path.basename(filePath);
494
+ const wantsPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
495
+ if (wantsPhoto) {
496
+ return this.sendPhoto(chatId, content, {
497
+ caption: opts.caption,
498
+ replyTo: opts.replyTo,
499
+ messageThreadId: opts.messageThreadId,
500
+ filename,
501
+ mimeType: mimeTypeForFilename(filename),
502
+ });
503
+ }
504
+ return this.sendDocument(chatId, content, filename, {
505
+ caption: opts.caption,
506
+ replyTo: opts.replyTo,
507
+ messageThreadId: opts.messageThreadId,
508
+ });
509
+ }
510
+ /** Set bottom menu commands and ensure the menu button is visible.
511
+ * Automatically applies to all known chats (from incoming updates). */
512
+ async setMenu(commands) {
513
+ this._menuCommands = commands;
514
+ await this.api('setMyCommands', { commands });
515
+ await this.api('setChatMenuButton', { menu_button: { type: 'commands' } });
516
+ for (const cid of this.knownChats) {
517
+ await this._applyMenuToChat(cid);
518
+ }
519
+ }
520
+ /** Track a chat ID; apply menu on first discovery. */
521
+ _trackChat(chatId) {
522
+ if (this.knownChats.has(chatId))
523
+ return;
524
+ this.knownChats.add(chatId);
525
+ this._applyMenuToChat(chatId).catch(() => { });
526
+ }
527
+ /** Apply cached menu commands to a single chat. */
528
+ async _applyMenuToChat(chatId) {
529
+ if (!this._menuCommands)
530
+ return;
531
+ await this.api('setMyCommands', {
532
+ commands: this._menuCommands,
533
+ scope: { type: 'chat', chat_id: chatId },
534
+ }).catch(() => { });
535
+ await this.api('setChatMenuButton', {
536
+ chat_id: chatId,
537
+ menu_button: { type: 'commands' },
538
+ }).catch(() => { });
539
+ }
540
+ /** Remove all bot commands and reset menu button to default. */
541
+ async clearMenu() {
542
+ this._menuCommands = null;
543
+ await this.api('deleteMyCommands', {}).catch(() => { });
544
+ await this.api('setChatMenuButton', { menu_button: { type: 'default' } }).catch(() => { });
545
+ for (const cid of this.knownChats) {
546
+ await this.api('deleteMyCommands', { scope: { type: 'chat', chat_id: cid } }).catch(() => { });
547
+ await this.api('setChatMenuButton', { chat_id: cid, menu_button: { type: 'default' } }).catch(() => { });
548
+ }
549
+ }
550
+ /** Drain pending updates (call before listen to skip stale messages). */
551
+ async drain() {
552
+ const data = await this.api('getUpdates', { offset: -1, timeout: 0 });
553
+ const results = data.result || [];
554
+ if (results.length)
555
+ this.offset = results[results.length - 1].update_id + 1;
556
+ return results.length;
557
+ }
558
+ /** Get the chat ID from the most recent incoming message (useful for 1v1 bot setup). */
559
+ async getRecentChatId() {
560
+ const data = await this.api('getUpdates', { offset: -1, timeout: 0 });
561
+ const results = data.result || [];
562
+ if (!results.length)
563
+ return null;
564
+ const u = results[results.length - 1];
565
+ return u.message?.chat?.id ?? u.callback_query?.message?.chat?.id ?? null;
566
+ }
567
+ /** Download a Telegram file to a local path. Returns the local path. */
568
+ async downloadFile(fileId, destFilename) {
569
+ const meta = await this.api('getFile', { file_id: fileId });
570
+ const filePath = meta.result.file_path;
571
+ const url = `https://api.telegram.org/file/bot${this.token}/${filePath}`;
572
+ const resp = await this._fetchResponse('Telegram file download', url, { method: 'GET' }, this.apiTimeout * 1000);
573
+ if (!resp.ok) {
574
+ const raw = await resp.text().catch(() => '');
575
+ throw new Error(`Telegram file download failed: HTTP ${resp.status} ${resp.statusText || ''}`.trim() + `; body=${previewText(raw)}`);
576
+ }
577
+ const ext = path.extname(filePath) || '.bin';
578
+ const name = destFilename || `tg_${fileId.slice(-8)}${ext}`;
579
+ const localPath = path.join(this.workdir, name);
580
+ fs.mkdirSync(this.workdir, { recursive: true });
581
+ if (resp.body) {
582
+ await pipeline(Readable.fromWeb(resp.body), fs.createWriteStream(localPath));
583
+ }
584
+ else {
585
+ const buf = Buffer.from(await resp.arrayBuffer());
586
+ fs.writeFileSync(localPath, buf);
587
+ }
588
+ return localPath;
589
+ }
590
+ // ========================================================================
591
+ // Low-level API
592
+ // ========================================================================
593
+ async api(method, payload) {
594
+ const timeout = method === 'getUpdates' ? (this.pollTimeout + 10) * 1000 : this.apiTimeout * 1000;
595
+ const resp = await this._fetchResponse(`Telegram API ${method}`, `${this.base}/${method}`, {
596
+ method: 'POST',
597
+ headers: { 'Content-Type': 'application/json' },
598
+ body: JSON.stringify(payload ?? {}),
599
+ }, timeout);
600
+ const data = await parseJsonResponse(resp, `Telegram API ${method}`);
601
+ if (!data.ok) {
602
+ if (method === 'getUpdates' && Number(data.error_code) === 409) {
603
+ const detail = typeof data.description === 'string' && data.description.trim()
604
+ ? data.description.trim()
605
+ : 'another getUpdates request is already running for this bot token';
606
+ throw new Error(`Telegram polling conflict: ${detail}`);
607
+ }
608
+ throw new Error(`Telegram API ${method}: ${JSON.stringify(data)}`);
609
+ }
610
+ return data;
611
+ }
612
+ // ========================================================================
613
+ // Internal: dispatch
614
+ // ========================================================================
615
+ async _dispatch(update) {
616
+ const key = this._queueKey(update);
617
+ if (!key) {
618
+ await this._dispatchNow(update);
619
+ return;
620
+ }
621
+ const prev = this.messageChains.get(key) || Promise.resolve();
622
+ const current = prev
623
+ .catch(() => { })
624
+ .then(() => this._dispatchNow(update));
625
+ const settled = current.finally(() => {
626
+ if (this.messageChains.get(key) === settled)
627
+ this.messageChains.delete(key);
628
+ });
629
+ this.messageChains.set(key, settled);
630
+ await settled;
631
+ }
632
+ _queueKey(update) {
633
+ const raw = update.message || update.edited_message;
634
+ if (!raw?.chat?.id)
635
+ return null;
636
+ const entities = raw.entities || [];
637
+ const cmdEntity = entities.find((e) => e.type === 'bot_command' && e.offset === 0);
638
+ if (cmdEntity)
639
+ return null;
640
+ return String(raw.chat.id);
641
+ }
642
+ async _dispatchNow(update) {
643
+ // callback query
644
+ if (update.callback_query) {
645
+ const cq = update.callback_query;
646
+ const chatId = cq.message?.chat?.id;
647
+ this._log(`[recv] callback_query id=${cq.id} chat=${chatId} from=${cq.from?.username || cq.from?.id} data="${cq.data}"`);
648
+ if (!chatId || !this._isAllowed(chatId)) {
649
+ this._log(`[recv] callback blocked: chat=${chatId} not allowed`);
650
+ return;
651
+ }
652
+ this._trackChat(chatId);
653
+ if (!this._hCallback)
654
+ return;
655
+ const ctx = this._makeCtx(chatId, cq.message?.message_id ?? 0, cq.from, cq);
656
+ ctx.callbackId = cq.id;
657
+ ctx.answerCallback = (text) => this.answerCallback(cq.id, text);
658
+ await this._hCallback(cq.data || '', ctx);
659
+ return;
660
+ }
661
+ // message
662
+ const raw = update.message || update.edited_message;
663
+ if (!raw || !raw.chat?.id)
664
+ return;
665
+ const chatId = raw.chat.id;
666
+ const fromUser = raw.from?.username || raw.from?.first_name || raw.from?.id || '?';
667
+ const msgPreview = (raw.text || raw.caption || '').slice(0, 120);
668
+ 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})` : ''}`);
669
+ if (!this._isAllowed(chatId)) {
670
+ this._log(`[recv] blocked: chat=${chatId} not in allowlist`);
671
+ return;
672
+ }
673
+ this._trackChat(chatId);
674
+ if (!this._shouldHandle(raw)) {
675
+ this._log(`[recv] skipped: not relevant (group mention/reply check)`);
676
+ return;
677
+ }
678
+ const from = { id: raw.from?.id, username: raw.from?.username, firstName: raw.from?.first_name };
679
+ const ctx = this._makeCtx(chatId, raw.message_id, from, raw);
680
+ // command — if no command handler registered, fall through to message handler
681
+ const entities = raw.entities || [];
682
+ const cmdEntity = entities.find((e) => e.type === 'bot_command' && e.offset === 0);
683
+ if (cmdEntity) {
684
+ const full = (raw.text || '').slice(cmdEntity.offset, cmdEntity.offset + cmdEntity.length);
685
+ const cmd = full.replace(/^\//, '').split('@')[0].toLowerCase();
686
+ const args = (raw.text || '').slice(cmdEntity.offset + cmdEntity.length).trim();
687
+ this._log(`[recv] command /${cmd} args="${args.slice(0, 80)}" chat=${chatId}`);
688
+ if (this._hCommand) {
689
+ await this._hCommand(cmd, args, ctx);
690
+ return;
691
+ }
692
+ }
693
+ // message (text + files aggregation)
694
+ if (!this._hMessage)
695
+ return;
696
+ const text = this._cleanMention(raw.text || raw.caption || '');
697
+ const files = [];
698
+ // download photo
699
+ if (raw.photo?.length) {
700
+ const best = raw.photo[raw.photo.length - 1];
701
+ this._log(`[recv] downloading photo file_id=${best.file_id} size=${best.width}x${best.height}`);
702
+ try {
703
+ const localPath = await this.downloadFile(best.file_id, `_tg_photo_${raw.message_id}.jpg`);
704
+ files.push(localPath);
705
+ this._log(`[recv] photo saved: ${localPath}`);
706
+ }
707
+ catch (e) {
708
+ this._log(`[recv] photo download failed: ${e}`);
709
+ this._hError?.(e);
710
+ }
711
+ }
712
+ // download document
713
+ if (raw.document) {
714
+ const origName = raw.document.file_name || `doc_${raw.message_id}`;
715
+ this._log(`[recv] downloading document "${origName}" file_id=${raw.document.file_id}`);
716
+ try {
717
+ const localPath = await this.downloadFile(raw.document.file_id, `_tg_${origName}`);
718
+ files.push(localPath);
719
+ this._log(`[recv] document saved: ${localPath}`);
720
+ }
721
+ catch (e) {
722
+ this._log(`[recv] document download failed: ${e}`);
723
+ this._hError?.(e);
724
+ }
725
+ }
726
+ this._log(`[dispatch] -> onMessage text="${text.slice(0, 80)}" files=${files.length} chat=${chatId}`);
727
+ await this._hMessage({ text, files }, ctx);
728
+ }
729
+ // ========================================================================
730
+ // Internal: helpers
731
+ // ========================================================================
732
+ _makeCtx(chatId, messageId, from, raw) {
733
+ const messageThreadId = typeof raw?.message_thread_id === 'number' ? raw.message_thread_id : undefined;
734
+ return {
735
+ chatId, messageId,
736
+ from: { id: from?.id, username: from?.username, firstName: from?.first_name },
737
+ reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId, messageThreadId: opts?.messageThreadId ?? messageThreadId }),
738
+ editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
739
+ answerCallback: () => Promise.resolve(),
740
+ channel: this,
741
+ raw,
742
+ };
743
+ }
744
+ _isAllowed(chatId) {
745
+ return this.allowedChatIds.size === 0 || this.allowedChatIds.has(chatId);
746
+ }
747
+ _shouldHandle(raw) {
748
+ const chatType = raw.chat?.type || '';
749
+ const text = (raw.text || raw.caption || '').trim();
750
+ const hasMedia = !!raw.photo || !!raw.document;
751
+ if (chatType === 'private')
752
+ return !!(text || hasMedia);
753
+ if ((raw.entities || []).some((e) => e.type === 'bot_command' && e.offset === 0))
754
+ return true;
755
+ if (!this.requireMention)
756
+ return !!(text || hasMedia);
757
+ const mention = this.bot?.username ? `@${this.bot.username.toLowerCase()}` : '';
758
+ if (mention && text.toLowerCase().includes(mention))
759
+ return true;
760
+ if (raw.reply_to_message?.from?.id === (this.bot?.id ?? 0))
761
+ return true;
762
+ return false;
763
+ }
764
+ _cleanMention(text) {
765
+ if (this.bot?.username)
766
+ text = text.replace(new RegExp(`@${this.bot.username}`, 'gi'), '');
767
+ return text.trim();
768
+ }
769
+ _log(msg) {
770
+ const ts = new Date().toTimeString().slice(0, 8);
771
+ process.stdout.write(`[telegram ${ts}] ${msg}\n`);
772
+ }
773
+ }