imtoagent 0.2.0

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.
Files changed (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,639 @@
1
+ // Telegram IM 适配器
2
+ // 实现 IMModule 接口,对接 Telegram Bot API(长轮询模式)
3
+ //
4
+ // 使用方式:config.json bot 配置 "im": "telegram"
5
+ // appId = Bot Token(从 @BotFather 获取)
6
+ // appSecret = 留空即可
7
+
8
+ import type { IMModule, IMCapabilities, MessageHandler } from '../types';
9
+ import type { UnifiedBlock } from '../capabilities';
10
+ import type { MessageAttachment } from '../core/types';
11
+ import { TelegramInboundAdapter, MediaStore, InboundMediaResolver } from '../media';
12
+
13
+ export interface TelegramConfig {
14
+ /** Bot Token(从 @BotFather 获取) */
15
+ token: string;
16
+ /** HTTP 代理地址(直连被墙时使用,如 http://127.0.0.1:7890) */
17
+ proxy?: string;
18
+ }
19
+
20
+ // ================================================================
21
+ // Telegram 消息类型(长轮询 getUpdates 返回的 message 结构)
22
+ // ================================================================
23
+
24
+ interface TelegramPhoto {
25
+ file_id: string;
26
+ file_unique_id: string;
27
+ file_size?: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ interface TelegramFile {
33
+ file_id: string;
34
+ file_unique_id: string;
35
+ file_size?: number;
36
+ file_name?: string;
37
+ mime_type?: string;
38
+ }
39
+
40
+ interface TelegramAudio {
41
+ file_id: string;
42
+ file_unique_id: string;
43
+ file_size?: number;
44
+ duration: number;
45
+ mime_type?: string;
46
+ file_name?: string;
47
+ }
48
+
49
+ interface TelegramVoice {
50
+ file_id: string;
51
+ file_unique_id: string;
52
+ file_size?: number;
53
+ duration: number;
54
+ mime_type?: string;
55
+ }
56
+
57
+ interface TelegramVideo {
58
+ file_id: string;
59
+ file_unique_id: string;
60
+ file_size?: number;
61
+ width: number;
62
+ height: number;
63
+ duration: number;
64
+ mime_type?: string;
65
+ file_name?: string;
66
+ }
67
+
68
+ interface TelegramMessage {
69
+ message_id: number;
70
+ date: number;
71
+ chat: { id: number; type: string; title?: string; username?: string };
72
+ from?: { id: number; is_bot: boolean; first_name: string; username?: string };
73
+ text?: string;
74
+ caption?: string;
75
+ photo?: TelegramPhoto[]; // 照片数组(按尺寸排列,取最后一个最大)
76
+ document?: TelegramFile;
77
+ audio?: TelegramAudio;
78
+ voice?: TelegramVoice;
79
+ video?: TelegramVideo;
80
+ sticker?: TelegramFile;
81
+ }
82
+
83
+ interface TelegramUpdate {
84
+ update_id: number;
85
+ message?: TelegramMessage;
86
+ edited_message?: TelegramMessage;
87
+ }
88
+
89
+ export class TelegramAdapter implements IMModule {
90
+ private token: string;
91
+ private apiUrl: string;
92
+ private proxy?: string;
93
+ private handler: MessageHandler | null = null;
94
+ private running = false;
95
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
96
+ private lastUpdateId = 0;
97
+
98
+ // ================================================================
99
+ // Inbound Media — 适配器 + 抽象层
100
+ // ================================================================
101
+ private _inboundAdapter: TelegramInboundAdapter | null = null;
102
+ private _mediaStore: MediaStore | null = null;
103
+ private _mediaResolver: InboundMediaResolver | null = null;
104
+
105
+ // ================================================================
106
+ // 熔断 + 指数退避:网络受限时避免刷屏和拖垮进程
107
+ // ================================================================
108
+ private consecutiveFailures = 0; // 连续失败次数
109
+ private circuitOpen = false; // 熔断器状态
110
+ private backoffMs = 100; // 当前退避间隔
111
+ private readonly maxBackoffMs = 60_000; // 最大退避 60s
112
+ private readonly failureThreshold = 5; // 连续 N 次失败后进入熔断
113
+ private readonly recoveryInterval = 30_000; // 熔断后每 30s 试探一次
114
+ private warnedCircuitOpen = false; // 避免重复打印熔断日志
115
+ private warnedPollError = false; // 避免重复打印轮询错误日志
116
+
117
+ constructor(cfg: TelegramConfig) {
118
+ this.token = cfg.token;
119
+ this.proxy = cfg.proxy;
120
+ this.apiUrl = `https://api.telegram.org/bot${this.token}`;
121
+
122
+ if (cfg.proxy) {
123
+ console.log(`[Telegram] 已配置代理: ${cfg.proxy}(局部使用,不影响其他模块)`);
124
+ }
125
+ }
126
+
127
+ // ================================================================
128
+ // 入站媒体层初始化(延迟创建,需要 token)
129
+ // ================================================================
130
+
131
+ private ensureMediaResolver(): InboundMediaResolver {
132
+ if (!this._mediaResolver) {
133
+ this._inboundAdapter = new TelegramInboundAdapter({
134
+ token: this.token,
135
+ // Share parent's proxy-aware fetch to avoid duplicating proxy logic
136
+ fetchFn: (url, init) => this._fetch(url, init),
137
+ });
138
+ this._mediaStore = new MediaStore();
139
+ this._mediaResolver = new InboundMediaResolver(this._inboundAdapter, this._mediaStore);
140
+ }
141
+ return this._mediaResolver;
142
+ }
143
+
144
+ // ================================================================
145
+ // 代理感知的 fetch
146
+ // ================================================================
147
+
148
+ private async _fetch(url: string, init?: RequestInit): Promise<Response> {
149
+ // Node.js 原生 fetch 不支持 proxy 选项
150
+ // 如果有代理配置,使用环境变量或 dispatcher(undici/Bun)
151
+ if (this.proxy) {
152
+ try {
153
+ // Bun 原生支持 proxy 选项
154
+ if ((globalThis as any).Bun) {
155
+ return fetch(url, { ...init, proxy: this.proxy } as any);
156
+ }
157
+ // Node.js: 尝试使用 undici 的 ProxyAgent
158
+ const { ProxyAgent } = await import('undici');
159
+ if (ProxyAgent) {
160
+ const dispatcher = new ProxyAgent(this.proxy);
161
+ return fetch(url, { ...init, dispatcher } as any);
162
+ }
163
+ } catch (e: any) {
164
+ // 降级:设置环境变量(影响全局,但总比没有好)
165
+ if (!process.env.HTTPS_PROXY && !process.env.https_proxy) {
166
+ process.env.HTTPS_PROXY = this.proxy;
167
+ console.log(`[Telegram] 已设置 HTTPS_PROXY=${this.proxy}`);
168
+ }
169
+ }
170
+ }
171
+ return fetch(url, init);
172
+ }
173
+
174
+ // ================================================================
175
+ // 能力声明
176
+ // ================================================================
177
+
178
+ getCapabilities(): IMCapabilities {
179
+ return {
180
+ text: true,
181
+ codeBlock: true, // MarkdownV2 支持 ``` 代码块
182
+ cardMessage: false, // Telegram 无原生卡片,sendBlocks 降级为文本
183
+ fileSend: true,
184
+ imageSend: true,
185
+ audioSend: true,
186
+ buttonAction: true, // 内联键盘
187
+ maxTextLength: 4096,
188
+ };
189
+ }
190
+
191
+ // ================================================================
192
+ // 生命周期
193
+ // ================================================================
194
+
195
+ start(handler: MessageHandler): void {
196
+ this.handler = handler;
197
+ this.running = true;
198
+ this._poll();
199
+ console.log('[Telegram] 长轮询已启动');
200
+ }
201
+
202
+ stop(): void {
203
+ this.running = false;
204
+ if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
205
+ console.log('[Telegram] 已停止');
206
+ }
207
+
208
+ // ================================================================
209
+ // 长轮询
210
+ // ================================================================
211
+
212
+ private async _poll(): Promise<void> {
213
+ if (!this.running) return;
214
+
215
+ let success = false;
216
+
217
+ try {
218
+ const url = `${this.apiUrl}/getUpdates?timeout=${this.circuitOpen ? 5 : 30}&offset=${this.lastUpdateId + 1}`;
219
+ const res = await this._fetch(url);
220
+ const data = await res.json();
221
+
222
+ if (data.ok && data.result) {
223
+ for (const update of data.result as TelegramUpdate[]) {
224
+ this.lastUpdateId = update.update_id;
225
+ const msg = update.message || update.edited_message;
226
+ if (!msg) continue;
227
+
228
+ // 解析消息文本和媒体附件
229
+ const { text, attachments } = await this._parseMessage(msg);
230
+ if (!text && attachments.length === 0) continue;
231
+
232
+ const chatId = String(msg.chat.id);
233
+ const userId = String(msg.from?.id || msg.chat.id);
234
+
235
+ if (this.handler) {
236
+ this.handler(chatId, text, userId, attachments.length > 0 ? attachments : undefined).catch(e =>
237
+ console.error('[Telegram] 消息处理异常:', e.message)
238
+ );
239
+ }
240
+ }
241
+ success = true;
242
+ }
243
+ } catch (e: any) {
244
+ if (!this.warnedPollError) {
245
+ console.error('[Telegram] 长轮询错误:', e.message);
246
+ this.warnedPollError = true;
247
+ }
248
+ }
249
+
250
+ if (success) {
251
+ this._onSuccess();
252
+ } else {
253
+ this._onFailure();
254
+ }
255
+
256
+ this.pollTimer = setTimeout(() => this._poll(), this.backoffMs);
257
+ }
258
+
259
+ // ================================================================
260
+ // 解析 Telegram 消息 — 提取文本 + 媒体附件
261
+ // ================================================================
262
+
263
+ private async _parseMessage(msg: TelegramMessage): Promise<{ text: string; attachments: MessageAttachment[] }> {
264
+ const attachments: MessageAttachment[] = [];
265
+ let text = msg.text || '';
266
+
267
+ // 收集所有媒体字段
268
+ const mediaItems: Array<{ fileId: string; type: 'image' | 'file' | 'media'; fileName?: string }> = [];
269
+
270
+ // 照片(取最大尺寸)
271
+ if (msg.photo && msg.photo.length > 0) {
272
+ const largest = msg.photo[msg.photo.length - 1];
273
+ mediaItems.push({ fileId: largest.file_id, type: 'image' });
274
+ }
275
+
276
+ // 文档/文件
277
+ if (msg.document) {
278
+ mediaItems.push({
279
+ fileId: msg.document.file_id,
280
+ type: 'file',
281
+ fileName: msg.document.file_name,
282
+ });
283
+ }
284
+
285
+ // 音频
286
+ if (msg.audio) {
287
+ mediaItems.push({
288
+ fileId: msg.audio.file_id,
289
+ type: 'media',
290
+ fileName: msg.audio.file_name,
291
+ });
292
+ }
293
+
294
+ // 语音
295
+ if (msg.voice) {
296
+ mediaItems.push({
297
+ fileId: msg.voice.file_id,
298
+ type: 'media',
299
+ fileName: 'voice.ogg',
300
+ });
301
+ }
302
+
303
+ // 视频
304
+ if (msg.video) {
305
+ mediaItems.push({
306
+ fileId: msg.video.file_id,
307
+ type: 'media',
308
+ fileName: msg.video.file_name || 'video.mp4',
309
+ });
310
+ }
311
+
312
+ // 贴纸
313
+ if (msg.sticker) {
314
+ mediaItems.push({
315
+ fileId: msg.sticker.file_id,
316
+ type: 'image',
317
+ fileName: 'sticker.webp',
318
+ });
319
+ }
320
+
321
+ // 下载媒体附件
322
+ if (mediaItems.length > 0) {
323
+ try {
324
+ const resolver = this.ensureMediaResolver();
325
+ const requests = mediaItems.map(item => ({
326
+ messageId: String(msg.message_id),
327
+ resourceKey: item.fileId,
328
+ type: item.type,
329
+ fileName: item.fileName,
330
+ }));
331
+
332
+ const result = await resolver.resolveAll(requests);
333
+ attachments.push(...result.attachments);
334
+
335
+ // 补充 Telegram 特有的字段
336
+ if (msg.audio) {
337
+ const audioAtt = attachments.find(a => a.type === 'audio');
338
+ if (audioAtt) audioAtt.durationMs = msg.audio.duration * 1000;
339
+ }
340
+ if (msg.voice) {
341
+ const voiceAtt = attachments.find(a => a.type === 'audio');
342
+ if (voiceAtt) voiceAtt.durationMs = msg.voice.duration * 1000;
343
+ }
344
+ } catch (e: any) {
345
+ console.error('[Telegram] 媒体解析失败:', e.message);
346
+ }
347
+ }
348
+
349
+ // 如果媒体有 caption,拼接到文本
350
+ if (msg.caption) {
351
+ text = text ? `${text}\n${msg.caption}` : msg.caption;
352
+ }
353
+
354
+ // 纯媒体消息(无文本无caption),生成占位文本
355
+ if (!text && attachments.length > 0) {
356
+ const types = attachments.map(a => {
357
+ if (a.type === 'image') return '图片';
358
+ if (a.type === 'audio') return '语音';
359
+ return '文件';
360
+ });
361
+ text = `[用户发送了${types.join('、')}]`;
362
+ }
363
+
364
+ return { text: text.trim(), attachments };
365
+ }
366
+
367
+ // ================================================================
368
+ // 熔断/退避
369
+ // ================================================================
370
+
371
+ /** 成功后重置所有状态 */
372
+ private _onSuccess(): void {
373
+ if (this.circuitOpen) {
374
+ console.log('[Telegram] 网络恢复,长轮询恢复正常');
375
+ }
376
+ this.circuitOpen = false;
377
+ this.consecutiveFailures = 0;
378
+ this.backoffMs = 100;
379
+ this.warnedCircuitOpen = false;
380
+ this.warnedPollError = false;
381
+ }
382
+
383
+ /** 失败后指数退避,超过阈值进入熔断 */
384
+ private _onFailure(): void {
385
+ this.consecutiveFailures++;
386
+
387
+ if (this.consecutiveFailures >= this.failureThreshold && !this.circuitOpen) {
388
+ this.circuitOpen = true;
389
+ this.backoffMs = this.recoveryInterval;
390
+ if (!this.warnedCircuitOpen) {
391
+ console.error('[Telegram] ⚠️ 连续失败,进入熔断(每 30s 试探恢复)');
392
+ this.warnedCircuitOpen = true;
393
+ }
394
+ } else if (!this.circuitOpen) {
395
+ // 指数退避: 100 → 200 → 400 → 800 → 1600 → ...
396
+ this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
397
+ }
398
+ // 熔断中保持 recoveryInterval 间隔
399
+ }
400
+
401
+ // ================================================================
402
+ // 文本发送
403
+ // ================================================================
404
+
405
+ async reply(chatId: string, text: string, maxLen = 4096): Promise<void> {
406
+ const safe = text.length > maxLen ? text.slice(0, maxLen) + '\n\n…(截断)' : text;
407
+ await this._api('sendMessage', {
408
+ chat_id: chatId,
409
+ text: safe,
410
+ parse_mode: 'MarkdownV2',
411
+ link_preview_options: { is_disabled: true },
412
+ }).catch(() =>
413
+ // MarkdownV2 解析失败时降级为纯文本
414
+ this._api('sendMessage', {
415
+ chat_id: chatId,
416
+ text: safe,
417
+ link_preview_options: { is_disabled: true },
418
+ })
419
+ );
420
+ }
421
+
422
+ async sendProgress(chatId: string, text: string): Promise<void> {
423
+ // Telegram 用"正在输入..."状态 + 临时消息
424
+ await this._api('sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => {});
425
+ await this.reply(chatId, text);
426
+ }
427
+
428
+ // ================================================================
429
+ // 富文本块发送(降级为 MarkdownV2 文本)
430
+ // ================================================================
431
+
432
+ async sendBlocks(chatId: string, blocks: UnifiedBlock[]): Promise<void> {
433
+ const lines: string[] = [];
434
+
435
+ for (const block of blocks) {
436
+ switch (block.type) {
437
+ case 'text':
438
+ lines.push(block.content);
439
+ break;
440
+
441
+ case 'code_block': {
442
+ const lang = block.language || '';
443
+ lines.push(`\`\`\`${lang}\n${block.code}\n\`\`\``);
444
+ break;
445
+ }
446
+
447
+ case 'image':
448
+ if (block.url) {
449
+ try {
450
+ await this.sendImageByUrl(chatId, block.url, block.alt);
451
+ } catch (e: any) {
452
+ lines.push(`⚠️ 图片加载失败`);
453
+ }
454
+ }
455
+ break;
456
+
457
+ case 'file':
458
+ if (block.url) {
459
+ try {
460
+ await this.sendFileByUrl(chatId, block.url, block.filename);
461
+ } catch (e: any) {
462
+ lines.push(`⚠️ 文件发送失败: ${block.filename}`);
463
+ }
464
+ }
465
+ break;
466
+
467
+ case 'card':
468
+ lines.push(`*${this._escape(block.title)}*`);
469
+ if (block.content) lines.push(block.content);
470
+ if (block.buttons?.length) {
471
+ await this._sendInlineButtons(chatId, lines.join('\n'), block.buttons);
472
+ return; // 按钮消息已发送,不继续拼接
473
+ }
474
+ break;
475
+
476
+ case 'table': {
477
+ const tableLines: string[] = [];
478
+ tableLines.push('| ' + block.headers.join(' | ') + ' |');
479
+ tableLines.push('| ' + block.headers.map(() => '---').join(' | ') + ' |');
480
+ for (const row of block.rows) {
481
+ tableLines.push('| ' + row.join(' | ') + ' |');
482
+ }
483
+ if (block.caption) lines.push(`*${this._escape(block.caption)}*`);
484
+ lines.push(tableLines.join('\n'));
485
+ break;
486
+ }
487
+
488
+ case 'audio':
489
+ if (block.url) {
490
+ try {
491
+ await this._sendAudio(chatId, block.url, block.filename);
492
+ } catch (e: any) {
493
+ lines.push(`⚠️ 音频发送失败`);
494
+ }
495
+ }
496
+ break;
497
+
498
+ case 'divider':
499
+ lines.push('---');
500
+ break;
501
+ }
502
+ }
503
+
504
+ const text = lines.join('\n\n').trim();
505
+ if (text) await this.reply(chatId, text);
506
+ }
507
+
508
+ // ================================================================
509
+ // 图片
510
+ // ================================================================
511
+
512
+ async sendImage(chatId: string, imageKey: string, alt?: string): Promise<void> {
513
+ // imageKey 可能是 file_id 或 URL
514
+ await this._api('sendPhoto', {
515
+ chat_id: chatId,
516
+ photo: imageKey,
517
+ caption: alt || '',
518
+ }).catch(async () => {
519
+ console.error(`[Telegram] 图片发送失败`);
520
+ });
521
+ }
522
+
523
+ private async sendImageByUrl(chatId: string, url: string, alt?: string): Promise<void> {
524
+ let imageSource: string;
525
+
526
+ if (url.startsWith('file://')) {
527
+ const filePath = url.replace('file://', '');
528
+ const blob = new Blob([require('fs').readFileSync(filePath)]);
529
+ const form = new FormData();
530
+ form.append('chat_id', chatId);
531
+ form.append('photo', blob, 'image.png');
532
+ if (alt) form.append('caption', alt);
533
+ await this._fetch(`${this.apiUrl}/sendPhoto`, { method: 'POST', body: form });
534
+ } else {
535
+ await this._api('sendPhoto', {
536
+ chat_id: chatId,
537
+ photo: url,
538
+ caption: alt || '',
539
+ });
540
+ }
541
+ }
542
+
543
+ // ================================================================
544
+ // 文件
545
+ // ================================================================
546
+
547
+ async sendFile(chatId: string, fileKey: string, fileName: string): Promise<void> {
548
+ await this._api('sendDocument', {
549
+ chat_id: chatId,
550
+ document: fileKey,
551
+ caption: fileName,
552
+ }).catch(() => {
553
+ console.error(`[Telegram] 文件发送失败: ${fileName}`);
554
+ });
555
+ }
556
+
557
+ private async sendFileByUrl(chatId: string, url: string, filename: string): Promise<void> {
558
+ if (url.startsWith('file://')) {
559
+ const filePath = url.replace('file://', '');
560
+ const buffer = require('fs').readFileSync(filePath);
561
+ const blob = new Blob([buffer]);
562
+ const form = new FormData();
563
+ form.append('chat_id', chatId);
564
+ form.append('document', blob, filename);
565
+ form.append('caption', filename);
566
+ await this._fetch(`${this.apiUrl}/sendDocument`, { method: 'POST', body: form });
567
+ } else {
568
+ await this._api('sendDocument', {
569
+ chat_id: chatId,
570
+ document: url,
571
+ caption: filename,
572
+ });
573
+ }
574
+ }
575
+
576
+ // ================================================================
577
+ // 音频
578
+ // ================================================================
579
+
580
+ private async _sendAudio(chatId: string, url: string, filename: string): Promise<void> {
581
+ if (url.startsWith('file://')) {
582
+ const filePath = url.replace('file://', '');
583
+ const buffer = require('fs').readFileSync(filePath);
584
+ const blob = new Blob([buffer]);
585
+ const form = new FormData();
586
+ form.append('chat_id', chatId);
587
+ form.append('audio', blob, filename);
588
+ await this._fetch(`${this.apiUrl}/sendAudio`, { method: 'POST', body: form });
589
+ } else {
590
+ await this._api('sendAudio', { chat_id: chatId, audio: url });
591
+ }
592
+ }
593
+
594
+ // ================================================================
595
+ // 内联按钮
596
+ // ================================================================
597
+
598
+ private async _sendInlineButtons(chatId: string, text: string, buttons: { label: string; url?: string }[]): Promise<void> {
599
+ const keyboard = {
600
+ inline_keyboard: [buttons.map(b => ({
601
+ text: b.label,
602
+ url: b.url || 'https://t.me',
603
+ }))],
604
+ };
605
+
606
+ await this._api('sendMessage', {
607
+ chat_id: chatId,
608
+ text,
609
+ parse_mode: 'MarkdownV2',
610
+ reply_markup: keyboard,
611
+ link_preview_options: { is_disabled: true },
612
+ }).catch(() =>
613
+ this._api('sendMessage', {
614
+ chat_id: chatId,
615
+ text,
616
+ reply_markup: keyboard,
617
+ link_preview_options: { is_disabled: true },
618
+ })
619
+ );
620
+ }
621
+
622
+ // ================================================================
623
+ // 工具方法
624
+ // ================================================================
625
+
626
+ private async _api(method: string, params: Record<string, any>): Promise<any> {
627
+ const res = await this._fetch(`${this.apiUrl}/${method}`, {
628
+ method: 'POST',
629
+ headers: { 'Content-Type': 'application/json' },
630
+ body: JSON.stringify(params),
631
+ });
632
+ return res.json();
633
+ }
634
+
635
+ /** MarkdownV2 转义(Telegram 要求转义特殊字符) */
636
+ private _escape(text: string): string {
637
+ return text.replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
638
+ }
639
+ }