@zhin.js/adapter-telegram 1.0.1 → 1.0.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.
package/src/index.ts DELETED
@@ -1,929 +0,0 @@
1
- import TelegramBotApi from "node-telegram-bot-api";
2
- import {
3
- Bot,
4
- BotConfig,
5
- Adapter,
6
- Plugin,
7
- registerAdapter,
8
- Message,
9
- SendOptions,
10
- SendContent,
11
- MessageSegment,
12
- segment,
13
- useContext
14
- } from "zhin.js";
15
- import type { Context } from 'koa';
16
- import { createWriteStream } from 'fs';
17
- import { SocksProxyAgent } from 'socks-proxy-agent';
18
- import { promises as fs } from 'fs';
19
- import path from 'path';
20
- import https from 'https';
21
- import http from 'http';
22
-
23
- // 声明模块,注册 telegram 适配器类型
24
- declare module 'zhin.js'{
25
- interface RegisteredAdapters{
26
- telegram: Adapter<TelegramBot>
27
- 'telegram-webhook': Adapter<TelegramWebhookBot>
28
- }
29
- }
30
-
31
- // 基础配置
32
- export interface TelegramBaseConfig extends BotConfig {
33
- token: string
34
- name: string
35
- proxy?: {
36
- host: string
37
- port: number
38
- username?: string
39
- password?: string
40
- }
41
- // 文件下载配置
42
- fileDownload?: {
43
- enabled: boolean
44
- downloadPath?: string
45
- maxFileSize?: number // 最大文件大小(字节)
46
- }
47
- }
48
-
49
- // Polling 模式配置
50
- export type TelegramBotConfig = TelegramBaseConfig & TelegramBotApi.ConstructorOptions & {
51
- context: 'telegram'
52
- mode?: 'polling' // 默认为 polling
53
- }
54
-
55
- // Webhook 模式配置
56
- export interface TelegramWebhookConfig extends TelegramBaseConfig {
57
- context: 'telegram-webhook'
58
- mode: 'webhook'
59
- webhookPath: string // webhook 路径,如 '/telegram/webhook'
60
- webhookUrl: string // 外部访问的 URL,如 'https://yourdomain.com/telegram/webhook'
61
- secretToken?: string // 可选的密钥令牌
62
- }
63
-
64
- // Bot 接口
65
- export interface TelegramBot {
66
- $config: TelegramBotConfig
67
- }
68
-
69
- export interface TelegramWebhookBot {
70
- $config: TelegramWebhookConfig
71
- }
72
-
73
- // 主要的 TelegramBot 类
74
- export class TelegramBot extends TelegramBotApi implements Bot<TelegramBotApi.Message, TelegramBotConfig> {
75
- $connected?: boolean
76
-
77
- constructor(private plugin: Plugin, config: TelegramBotConfig) {
78
- const options: TelegramBotApi.ConstructorOptions = {
79
- polling: true,
80
- ...config
81
- };
82
-
83
- // 如果配置了代理,设置代理
84
- if (config.proxy) {
85
- try {
86
- const proxyUrl = `socks5://${config.proxy.username ? `${config.proxy.username}:${config.proxy.password}@` : ''}${config.proxy.host}:${config.proxy.port}`;
87
- options.request = {
88
- agent: new SocksProxyAgent(proxyUrl)
89
- } as any;
90
- } catch (error) {
91
- // 代理配置失败,继续不使用代理
92
- console.warn('Failed to configure proxy, continuing without proxy:', error);
93
- }
94
- }
95
-
96
- super(config.token, options);
97
- this.$config = config;
98
- this.$connected = false;
99
- }
100
-
101
- private async handleTelegramMessage(msg: TelegramBotApi.Message): Promise<void> {
102
- const message = this.$formatMessage(msg);
103
- this.plugin.dispatch('message.receive', message);
104
- this.plugin.logger.info(`recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`);
105
- this.plugin.dispatch(`message.${message.$channel.type}.receive`, message);
106
- }
107
-
108
- async $connect(): Promise<void> {
109
- return new Promise((resolve, reject) => {
110
- // 监听所有消息
111
- this.on('message', this.handleTelegramMessage.bind(this));
112
-
113
- // 监听连接错误
114
- this.on('polling_error', (error) => {
115
- this.plugin.logger.error('Telegram polling error:', error);
116
- this.$connected = false;
117
- reject(error);
118
- });
119
-
120
- // 启动轮询,测试连接
121
- this.startPolling().then(() => {
122
- this.$connected = true;
123
- this.plugin.logger.info(`Telegram bot ${this.$config.name} connected successfully`);
124
- resolve();
125
- }).catch((error) => {
126
- this.plugin.logger.error('Failed to start Telegram bot:', error);
127
- this.$connected = false;
128
- reject(error);
129
- });
130
- });
131
- }
132
-
133
- async $disconnect(): Promise<void> {
134
- try {
135
- await this.stopPolling();
136
- this.$connected = false;
137
- this.plugin.logger.info(`Telegram bot ${this.$config.name} disconnected`);
138
- } catch (error) {
139
- this.plugin.logger.error('Error disconnecting Telegram bot:', error);
140
- throw error;
141
- }
142
- }
143
-
144
- $formatMessage(msg: TelegramBotApi.Message): Message<TelegramBotApi.Message> {
145
- // 确定聊天类型和ID
146
- let channelType: 'private' | 'group' | 'channel';
147
- let channelId: string;
148
-
149
- if (msg.chat.type === 'private') {
150
- channelType = 'private';
151
- channelId = msg.chat.id.toString();
152
- } else if (msg.chat.type === 'group' || msg.chat.type === 'supergroup') {
153
- channelType = 'group';
154
- channelId = msg.chat.id.toString();
155
- } else if (msg.chat.type === 'channel') {
156
- channelType = 'channel';
157
- channelId = msg.chat.id.toString();
158
- } else {
159
- channelType = 'private';
160
- channelId = msg.chat.id.toString();
161
- }
162
-
163
- // 转换消息内容为 segment 格式(同步)
164
- const content = this.parseMessageContentSync(msg);
165
-
166
- // 在后台异步下载文件(不阻塞消息处理)
167
- this.downloadMessageFiles(msg).catch(error => {
168
- this.plugin.logger.warn('Failed to download message files:', error);
169
- });
170
-
171
- const result = Message.from(msg, {
172
- $id: msg.message_id.toString(),
173
- $adapter: 'telegram',
174
- $bot: this.$config.name,
175
- $sender: {
176
- id: msg.from?.id.toString() || msg.chat.id.toString(),
177
- name: msg.from ? (msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : '')) : msg.chat.title || 'Unknown'
178
- },
179
- $channel: {
180
- id: channelId,
181
- type: channelType
182
- },
183
- $content: content,
184
- $raw: msg.text || msg.caption || '',
185
- $timestamp: msg.date * 1000, // Telegram 使用秒,转换为毫秒
186
- $reply: async (content: SendContent, quote?: boolean | string): Promise<void> => {
187
- if (!Array.isArray(content)) content = [content];
188
-
189
- const sendOptions: any = {};
190
-
191
- // 处理回复消息
192
- if (quote) {
193
- sendOptions.reply_to_message_id = parseInt(
194
- typeof quote === "boolean" ? result.$id : quote
195
- );
196
- }
197
-
198
- await this.sendContentToChat(channelId, content, sendOptions);
199
- }
200
- });
201
-
202
- return result;
203
- }
204
-
205
- async $sendMessage(options: SendOptions): Promise<void> {
206
- options = await this.plugin.app.handleBeforeSend(options);
207
-
208
- try {
209
- const chatId = options.id;
210
- await this.sendContentToChat(chatId, options.content);
211
- this.plugin.logger.info(`send ${options.type}(${options.id}): ${segment.raw(options.content)}`);
212
- } catch (error) {
213
- this.plugin.logger.error('Failed to send Telegram message:', error);
214
- throw error;
215
- }
216
- }
217
-
218
- // 发送内容到聊天
219
- async sendContentToChat(chatId: string, content: SendContent, extraOptions: any = {}): Promise<void> {
220
- if (!Array.isArray(content)) content = [content];
221
-
222
- const numericChatId = parseInt(chatId);
223
- let replyToMessageId = extraOptions.reply_to_message_id;
224
-
225
- for (const segment of content) {
226
- if (typeof segment === 'string') {
227
- await this.sendMessage(numericChatId, segment, {
228
- parse_mode: 'HTML',
229
- reply_to_message_id: replyToMessageId,
230
- ...extraOptions
231
- });
232
- replyToMessageId = undefined; // 只有第一条消息回复
233
- continue;
234
- }
235
-
236
- const { type, data } = segment;
237
-
238
- switch (type) {
239
- case 'text':
240
- await this.sendMessage(numericChatId, data.text, {
241
- parse_mode: 'HTML',
242
- reply_to_message_id: replyToMessageId,
243
- ...extraOptions
244
- });
245
- break;
246
-
247
- case 'image':
248
- await this.sendPhoto(numericChatId, data.file || data.url || data.file_id, {
249
- caption: data.caption,
250
- reply_to_message_id: replyToMessageId,
251
- ...extraOptions
252
- });
253
- break;
254
-
255
- case 'audio':
256
- await this.sendAudio(numericChatId, data.file || data.url || data.file_id, {
257
- duration: data.duration,
258
- performer: data.performer,
259
- title: data.title,
260
- caption: data.caption,
261
- reply_to_message_id: replyToMessageId,
262
- ...extraOptions
263
- });
264
- break;
265
-
266
- case 'voice':
267
- await this.sendVoice(numericChatId, data.file || data.url || data.file_id, {
268
- duration: data.duration,
269
- caption: data.caption,
270
- reply_to_message_id: replyToMessageId,
271
- ...extraOptions
272
- });
273
- break;
274
-
275
- case 'video':
276
- await this.sendVideo(numericChatId, data.file || data.url || data.file_id, {
277
- duration: data.duration,
278
- width: data.width,
279
- height: data.height,
280
- caption: data.caption,
281
- reply_to_message_id: replyToMessageId,
282
- ...extraOptions
283
- });
284
- break;
285
-
286
- case 'video_note':
287
- await this.sendVideoNote(numericChatId, data.file || data.url || data.file_id, {
288
- duration: data.duration,
289
- length: data.length,
290
- reply_to_message_id: replyToMessageId,
291
- ...extraOptions
292
- });
293
- break;
294
-
295
- case 'document':
296
- case 'file':
297
- await this.sendDocument(numericChatId, data.file || data.url || data.file_id, {
298
- caption: data.caption,
299
- reply_to_message_id: replyToMessageId,
300
- ...extraOptions
301
- });
302
- break;
303
-
304
- case 'sticker':
305
- await this.sendSticker(numericChatId, data.file_id, {
306
- reply_to_message_id: replyToMessageId,
307
- ...extraOptions
308
- });
309
- break;
310
-
311
- case 'location':
312
- await this.sendLocation(numericChatId, data.latitude, data.longitude, {
313
- live_period: data.live_period,
314
- heading: data.heading,
315
- proximity_alert_radius: data.proximity_alert_radius,
316
- reply_to_message_id: replyToMessageId,
317
- ...extraOptions
318
- });
319
- break;
320
-
321
- case 'contact':
322
- await this.sendContact(numericChatId, data.phone_number, data.first_name, {
323
- last_name: data.last_name,
324
- vcard: data.vcard,
325
- reply_to_message_id: replyToMessageId,
326
- ...extraOptions
327
- });
328
- break;
329
-
330
- case 'reply':
331
- // 回复消息在 extraOptions 中处理
332
- replyToMessageId = parseInt(data.id);
333
- break;
334
-
335
- case 'at':
336
- // @提及转换为文本
337
- const mentionText = data.name ? `@${data.name}` : data.text || `@${data.id}`;
338
- await this.sendMessage(numericChatId, mentionText, {
339
- reply_to_message_id: replyToMessageId,
340
- ...extraOptions
341
- });
342
- break;
343
-
344
- default:
345
- // 未知类型,作为文本处理
346
- const text = data.text || `[${type}]`;
347
- await this.sendMessage(numericChatId, text, {
348
- reply_to_message_id: replyToMessageId,
349
- ...extraOptions
350
- });
351
- }
352
-
353
- replyToMessageId = undefined; // 只有第一条消息回复
354
- }
355
- }
356
-
357
- // 获取文件信息并下载(如果启用)
358
- async getFileInfo(fileId: string) {
359
- try {
360
- const file = await this.getFile(fileId);
361
- const fileUrl = `https://api.telegram.org/file/bot${this.$config.token}/${file.file_path}`;
362
-
363
- return {
364
- file_id: fileId,
365
- file_unique_id: file.file_unique_id,
366
- file_size: file.file_size,
367
- file_path: file.file_path,
368
- file_url: fileUrl
369
- };
370
- } catch (error) {
371
- this.plugin.logger.error('Failed to get file info:', error);
372
- return null;
373
- }
374
- }
375
-
376
- // 下载文件到本地
377
- async downloadTelegramFile(fileId: string): Promise<string | null> {
378
- if (!this.$config.fileDownload?.enabled) {
379
- return null;
380
- }
381
-
382
- const fileInfo = await this.getFileInfo(fileId);
383
- if (!fileInfo) return null;
384
-
385
- const downloadPath = this.$config.fileDownload.downloadPath || './downloads';
386
- const maxFileSize = this.$config.fileDownload.maxFileSize || 20 * 1024 * 1024; // 20MB
387
-
388
- if (fileInfo.file_size && fileInfo.file_size > maxFileSize) {
389
- this.plugin.logger.warn(`File too large: ${fileInfo.file_size} bytes`);
390
- return null;
391
- }
392
-
393
- try {
394
- await fs.mkdir(downloadPath, { recursive: true });
395
- const fileName = `${fileId}_${Date.now()}.${path.extname(fileInfo.file_path || '')}`;
396
- const localPath = path.join(downloadPath, fileName);
397
-
398
- return new Promise((resolve, reject) => {
399
- const client = fileInfo.file_url?.startsWith('https:') ? https : http;
400
- const request = client.get(fileInfo.file_url!, (response) => {
401
- if (response.statusCode === 200) {
402
- const fileStream = createWriteStream(localPath);
403
- response.pipe(fileStream);
404
- fileStream.on('finish', () => resolve(localPath));
405
- fileStream.on('error', reject);
406
- } else {
407
- reject(new Error(`HTTP ${response.statusCode}`));
408
- }
409
- });
410
- request.on('error', reject);
411
- });
412
- } catch (error) {
413
- this.plugin.logger.error('Failed to download file:', error);
414
- return null;
415
- }
416
- }
417
-
418
- // 同步解析 Telegram 消息内容为 segment 格式(不包含文件下载)
419
- parseMessageContentSync(msg: TelegramBotApi.Message): MessageSegment[] {
420
- const segments: MessageSegment[] = [];
421
-
422
- // 回复消息处理
423
- if (msg.reply_to_message) {
424
- segments.push({
425
- type: 'reply',
426
- data: {
427
- id: msg.reply_to_message.message_id.toString(),
428
- message: this.parseMessageContentSync(msg.reply_to_message)
429
- }
430
- });
431
- }
432
-
433
- // 文本消息(包含实体处理)
434
- if (msg.text) {
435
- segments.push(...this.parseTextWithEntities(msg.text, msg.entities));
436
- }
437
-
438
- // 图片消息
439
- if (msg.photo && msg.photo.length > 0) {
440
- const photo = msg.photo[msg.photo.length - 1]; // 取最大尺寸的图片
441
-
442
- segments.push({
443
- type: 'image',
444
- data: {
445
- file_id: photo.file_id,
446
- file_unique_id: photo.file_unique_id,
447
- width: photo.width,
448
- height: photo.height,
449
- file_size: photo.file_size
450
- }
451
- });
452
-
453
- if (msg.caption) {
454
- segments.push(...this.parseTextWithEntities(msg.caption, msg.caption_entities));
455
- }
456
- }
457
-
458
- // 音频消息
459
- if (msg.audio) {
460
- segments.push({
461
- type: 'audio',
462
- data: {
463
- file_id: msg.audio.file_id,
464
- file_unique_id: msg.audio.file_unique_id,
465
- duration: msg.audio.duration,
466
- performer: msg.audio.performer,
467
- title: msg.audio.title,
468
- mime_type: msg.audio.mime_type,
469
- file_size: msg.audio.file_size
470
- }
471
- });
472
- }
473
-
474
- // 语音消息
475
- if (msg.voice) {
476
- segments.push({
477
- type: 'voice',
478
- data: {
479
- file_id: msg.voice.file_id,
480
- file_unique_id: msg.voice.file_unique_id,
481
- duration: msg.voice.duration,
482
- mime_type: msg.voice.mime_type,
483
- file_size: msg.voice.file_size
484
- }
485
- });
486
- }
487
-
488
- // 视频消息
489
- if (msg.video) {
490
- segments.push({
491
- type: 'video',
492
- data: {
493
- file_id: msg.video.file_id,
494
- file_unique_id: msg.video.file_unique_id,
495
- width: msg.video.width,
496
- height: msg.video.height,
497
- duration: msg.video.duration,
498
- mime_type: msg.video.mime_type,
499
- file_size: msg.video.file_size
500
- }
501
- });
502
-
503
- if (msg.caption) {
504
- segments.push(...this.parseTextWithEntities(msg.caption, msg.caption_entities));
505
- }
506
- }
507
-
508
- // 视频笔记(圆形视频)
509
- if (msg.video_note) {
510
- segments.push({
511
- type: 'video_note',
512
- data: {
513
- file_id: msg.video_note.file_id,
514
- file_unique_id: msg.video_note.file_unique_id,
515
- length: msg.video_note.length,
516
- duration: msg.video_note.duration,
517
- file_size: msg.video_note.file_size
518
- }
519
- });
520
- }
521
-
522
- // 文档消息
523
- if (msg.document) {
524
- segments.push({
525
- type: 'document',
526
- data: {
527
- file_id: msg.document.file_id,
528
- file_unique_id: msg.document.file_unique_id,
529
- file_name: msg.document.file_name,
530
- mime_type: msg.document.mime_type,
531
- file_size: msg.document.file_size
532
- }
533
- });
534
-
535
- if (msg.caption) {
536
- segments.push(...this.parseTextWithEntities(msg.caption, msg.caption_entities));
537
- }
538
- }
539
-
540
- // 贴纸消息
541
- if (msg.sticker) {
542
- segments.push({
543
- type: 'sticker',
544
- data: {
545
- file_id: msg.sticker.file_id,
546
- file_unique_id: msg.sticker.file_unique_id,
547
- type: msg.sticker.type,
548
- width: msg.sticker.width,
549
- height: msg.sticker.height,
550
- is_animated: msg.sticker.is_animated,
551
- is_video: msg.sticker.is_video,
552
- emoji: msg.sticker.emoji,
553
- set_name: msg.sticker.set_name,
554
- file_size: msg.sticker.file_size
555
- }
556
- });
557
- }
558
-
559
- // 位置消息
560
- if (msg.location) {
561
- segments.push({
562
- type: 'location',
563
- data: {
564
- longitude: msg.location.longitude,
565
- latitude: msg.location.latitude
566
- }
567
- });
568
- }
569
-
570
- // 联系人消息
571
- if (msg.contact) {
572
- segments.push({
573
- type: 'contact',
574
- data: {
575
- phone_number: msg.contact.phone_number,
576
- first_name: msg.contact.first_name,
577
- last_name: msg.contact.last_name,
578
- user_id: msg.contact.user_id,
579
- vcard: msg.contact.vcard
580
- }
581
- });
582
- }
583
-
584
- return segments.length > 0 ? segments : [{ type: 'text', data: { text: '' } }];
585
- }
586
-
587
- // 在后台异步下载消息中的文件
588
- async downloadMessageFiles(msg: TelegramBotApi.Message): Promise<void> {
589
- if (!this.$config.fileDownload?.enabled) {
590
- return;
591
- }
592
-
593
- const fileIds: string[] = [];
594
-
595
- // 收集需要下载的文件ID
596
- if (msg.photo && msg.photo.length > 0) {
597
- fileIds.push(msg.photo[msg.photo.length - 1].file_id);
598
- }
599
- if (msg.audio) {
600
- fileIds.push(msg.audio.file_id);
601
- }
602
- if (msg.voice) {
603
- fileIds.push(msg.voice.file_id);
604
- }
605
- if (msg.video) {
606
- fileIds.push(msg.video.file_id);
607
- }
608
- if (msg.video_note) {
609
- fileIds.push(msg.video_note.file_id);
610
- }
611
- if (msg.document) {
612
- fileIds.push(msg.document.file_id);
613
- }
614
- if (msg.sticker) {
615
- fileIds.push(msg.sticker.file_id);
616
- }
617
-
618
- // 并行下载所有文件
619
- const downloadPromises = fileIds.map(fileId =>
620
- this.downloadTelegramFile(fileId).catch(error => {
621
- this.plugin.logger.warn(`Failed to download file ${fileId}:`, error);
622
- return null;
623
- })
624
- );
625
-
626
- await Promise.all(downloadPromises);
627
- }
628
-
629
- // 解析文本实体(@mentions, #hashtags, URLs, etc.)
630
- parseTextWithEntities(text: string, entities?: TelegramBotApi.MessageEntity[]): MessageSegment[] {
631
- if (!entities || entities.length === 0) {
632
- return [{ type: 'text', data: { text } }];
633
- }
634
-
635
- const segments: MessageSegment[] = [];
636
- let lastOffset = 0;
637
-
638
- entities.forEach(entity => {
639
- // 添加实体前的文本
640
- if (entity.offset > lastOffset) {
641
- segments.push({
642
- type: 'text',
643
- data: { text: text.slice(lastOffset, entity.offset) }
644
- });
645
- }
646
-
647
- const entityText = text.slice(entity.offset, entity.offset + entity.length);
648
-
649
- switch (entity.type) {
650
- case 'mention':
651
- segments.push({
652
- type: 'at',
653
- data: { text: entityText }
654
- });
655
- break;
656
- case 'text_mention':
657
- segments.push({
658
- type: 'at',
659
- data: {
660
- id: entity.user?.id?.toString(),
661
- name: entity.user?.first_name,
662
- text: entityText
663
- }
664
- });
665
- break;
666
- case 'hashtag':
667
- segments.push({
668
- type: 'hashtag',
669
- data: { text: entityText }
670
- });
671
- break;
672
- case 'url':
673
- case 'text_link':
674
- segments.push({
675
- type: 'link',
676
- data: {
677
- url: entity.url || entityText,
678
- text: entityText
679
- }
680
- });
681
- break;
682
- case 'bold':
683
- segments.push({
684
- type: 'text',
685
- data: { text: `<b>${entityText}</b>` }
686
- });
687
- break;
688
- case 'italic':
689
- segments.push({
690
- type: 'text',
691
- data: { text: `<i>${entityText}</i>` }
692
- });
693
- break;
694
- case 'code':
695
- segments.push({
696
- type: 'text',
697
- data: { text: `<code>${entityText}</code>` }
698
- });
699
- break;
700
- case 'pre':
701
- segments.push({
702
- type: 'text',
703
- data: { text: `<pre>${entityText}</pre>` }
704
- });
705
- break;
706
- default:
707
- segments.push({
708
- type: 'text',
709
- data: { text: entityText }
710
- });
711
- }
712
-
713
- lastOffset = entity.offset + entity.length;
714
- });
715
-
716
- // 添加最后剩余的文本
717
- if (lastOffset < text.length) {
718
- segments.push({
719
- type: 'text',
720
- data: { text: text.slice(lastOffset) }
721
- });
722
- }
723
-
724
- return segments;
725
- }
726
-
727
- // 工具方法:检查文件是否存在
728
- static async fileExists(filePath: string): Promise<boolean> {
729
- try {
730
- await fs.access(filePath);
731
- return true;
732
- } catch {
733
- return false;
734
- }
735
- }
736
-
737
- // 工具方法:获取文件扩展名
738
- static getFileExtension(fileName: string): string {
739
- return path.extname(fileName).toLowerCase();
740
- }
741
-
742
- // 工具方法:判断是否为图片文件
743
- static isImageFile(fileName: string): boolean {
744
- const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
745
- return imageExtensions.includes(this.getFileExtension(fileName));
746
- }
747
-
748
- // 工具方法:判断是否为音频文件
749
- static isAudioFile(fileName: string): boolean {
750
- const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
751
- return audioExtensions.includes(this.getFileExtension(fileName));
752
- }
753
-
754
- // 工具方法:判断是否为视频文件
755
- static isVideoFile(fileName: string): boolean {
756
- const videoExtensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm'];
757
- return videoExtensions.includes(this.getFileExtension(fileName));
758
- }
759
-
760
- // 静态方法:将 SendContent 格式化为纯文本(用于日志显示)
761
- static formatContentToText(content: SendContent): string {
762
- if (!Array.isArray(content)) content = [content];
763
-
764
- return content.map(segment => {
765
- if (typeof segment === 'string') return segment;
766
-
767
- switch (segment.type) {
768
- case 'text':
769
- return segment.data.text || '';
770
- case 'at':
771
- return `@${segment.data.name || segment.data.id}`;
772
- case 'image':
773
- return '[图片]';
774
- case 'audio':
775
- return '[音频]';
776
- case 'voice':
777
- return '[语音]';
778
- case 'video':
779
- return '[视频]';
780
- case 'video_note':
781
- return '[视频笔记]';
782
- case 'document':
783
- case 'file':
784
- return '[文件]';
785
- case 'sticker':
786
- return '[贴纸]';
787
- case 'location':
788
- return '[位置]';
789
- case 'contact':
790
- return '[联系人]';
791
- default:
792
- return `[${segment.type}]`;
793
- }
794
- }).join('');
795
- }
796
- }
797
-
798
- // ================================================================================================
799
- // TelegramWebhookBot 类(Webhook 模式)
800
- // ================================================================================================
801
-
802
- export class TelegramWebhookBot extends TelegramBotApi implements Bot<TelegramBotApi.Message, TelegramWebhookConfig> {
803
- $connected?: boolean
804
- private router: any
805
-
806
- constructor(private plugin: Plugin, router: any, config: TelegramWebhookConfig) {
807
- // webhook 模式不需要 polling
808
- const options: TelegramBotApi.ConstructorOptions = {
809
- webHook: false,
810
- polling: false
811
- };
812
-
813
- // 如果配置了代理,设置代理
814
- if (config.proxy) {
815
- try {
816
- const proxyUrl = `socks5://${config.proxy.username ? `${config.proxy.username}:${config.proxy.password}@` : ''}${config.proxy.host}:${config.proxy.port}`;
817
- options.request = {
818
- agent: new SocksProxyAgent(proxyUrl)
819
- } as any;
820
- } catch (error) {
821
- console.warn('Failed to configure proxy, continuing without proxy:', error);
822
- }
823
- }
824
-
825
- super(config.token, options);
826
- this.$config = config;
827
- this.$connected = false;
828
- this.router = router;
829
-
830
- // 设置 webhook 路由
831
- this.setupWebhookRoute();
832
- }
833
-
834
- private setupWebhookRoute(): void {
835
- this.router.post(this.$config.webhookPath, (ctx: Context) => {
836
- this.handleWebhook(ctx);
837
- });
838
- }
839
-
840
- private async handleWebhook(ctx: Context): Promise<void> {
841
- try {
842
- // 验证密钥令牌(如果配置了)
843
- if (this.$config.secretToken) {
844
- const authHeader = ctx.get('x-telegram-bot-api-secret-token');
845
- if (authHeader !== this.$config.secretToken) {
846
- this.plugin.logger.warn('Invalid secret token in webhook');
847
- ctx.status = 403;
848
- ctx.body = 'Forbidden';
849
- return;
850
- }
851
- }
852
-
853
- const update = (ctx.request as any).body;
854
-
855
- if (update.message) {
856
- await this.handleTelegramMessage(update.message);
857
- }
858
-
859
- ctx.status = 200;
860
- ctx.body = 'OK';
861
- } catch (error) {
862
- this.plugin.logger.error('Webhook error:', error);
863
- ctx.status = 500;
864
- ctx.body = 'Internal Server Error';
865
- }
866
- }
867
-
868
- private async handleTelegramMessage(msg: TelegramBotApi.Message): Promise<void> {
869
- const message = this.$formatMessage(msg);
870
- this.plugin.dispatch('message.receive', message);
871
- this.plugin.logger.info(`recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`);
872
- this.plugin.dispatch(`message.${message.$channel.type}.receive`, message);
873
- }
874
-
875
- async $connect(): Promise<void> {
876
- try {
877
- // 设置 webhook URL
878
- await this.setWebHook(this.$config.webhookUrl, {
879
- secret_token: this.$config.secretToken
880
- });
881
-
882
- this.$connected = true;
883
- this.plugin.logger.info(`Telegram webhook bot connected: ${this.$config.name}`);
884
- this.plugin.logger.info(`Webhook URL: ${this.$config.webhookUrl}`);
885
-
886
- } catch (error) {
887
- this.plugin.logger.error('Failed to set webhook:', error);
888
- throw error;
889
- }
890
- }
891
-
892
- async $disconnect(): Promise<void> {
893
- try {
894
- await this.deleteWebHook();
895
- this.$connected = false;
896
- this.plugin.logger.info('Telegram webhook disconnected');
897
- } catch (error) {
898
- this.plugin.logger.error('Error disconnecting webhook:', error);
899
- }
900
- }
901
-
902
- // 复用原有的消息格式化和发送方法
903
- $formatMessage(msg: TelegramBotApi.Message): Message<TelegramBotApi.Message> {
904
- return TelegramBot.prototype.$formatMessage.call(this, msg);
905
- }
906
-
907
- async $sendMessage(options: SendOptions): Promise<void> {
908
- return TelegramBot.prototype.$sendMessage.call(this, options);
909
- }
910
-
911
- // 复用文件下载方法
912
- private async downloadTelegramFile(fileId: string): Promise<string | null> {
913
- return (TelegramBot.prototype as any).downloadTelegramFile.call(this, fileId);
914
- }
915
-
916
- // 静态方法引用
917
- static parseMessageContent = (TelegramBot as any).parseMessageContent;
918
- static formatSendContent = (TelegramBot as any).formatSendContent;
919
- }
920
-
921
- // 注册 polling 模式适配器
922
- registerAdapter(new Adapter('telegram', (plugin: Plugin, config: any) => new TelegramBot(plugin, config as TelegramBotConfig)))
923
-
924
- // 注册 webhook 模式适配器(需要 router)
925
- useContext('router', (router) => {
926
- registerAdapter(new Adapter('telegram-webhook',
927
- (plugin: Plugin, config: any) => new TelegramWebhookBot(plugin, router, config as TelegramWebhookConfig)
928
- ));
929
- });