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