@zhin.js/adapter-telegram 5.0.2 → 5.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +61 -144
  3. package/adapters/telegram.ts +27 -0
  4. package/agent/tools/create_invite.ts +2 -2
  5. package/agent/tools/list_admins.ts +2 -2
  6. package/agent/tools/member_count.ts +2 -2
  7. package/agent/tools/pin_message.ts +2 -2
  8. package/agent/tools/react.ts +2 -2
  9. package/agent/tools/send_poll.ts +2 -2
  10. package/agent/tools/send_sticker.ts +2 -2
  11. package/agent/tools/set_description.ts +2 -2
  12. package/agent/tools/set_permissions.ts +2 -2
  13. package/agent/tools/unpin_message.ts +2 -2
  14. package/lib/endpoint.d.ts +65 -0
  15. package/lib/endpoint.js +332 -0
  16. package/lib/index.d.ts +4 -0
  17. package/lib/index.js +4 -0
  18. package/lib/platform-permit.d.ts +18 -0
  19. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  20. package/lib/polling.d.ts +9 -0
  21. package/lib/polling.js +52 -0
  22. package/lib/protocol.d.ts +250 -0
  23. package/lib/protocol.js +324 -0
  24. package/lib/telegram-agent-deps.d.ts +28 -0
  25. package/lib/telegram-agent-deps.js +30 -0
  26. package/lib/webhook.d.ts +13 -0
  27. package/lib/webhook.js +45 -0
  28. package/package.json +43 -40
  29. package/plugin.ts +13 -0
  30. package/schema.json +39 -0
  31. package/src/endpoint.ts +339 -1103
  32. package/src/index.ts +40 -186
  33. package/src/platform-permit.ts +1 -1
  34. package/src/polling.ts +71 -0
  35. package/src/protocol.ts +573 -0
  36. package/src/telegram-agent-deps.ts +52 -11
  37. package/src/webhook.ts +67 -0
  38. package/client/Dashboard.tsx +0 -295
  39. package/client/index.tsx +0 -11
  40. package/client/tsconfig.json +0 -7
  41. package/client/utils/api.ts +0 -30
  42. package/dist/index.js +0 -32
  43. package/lib/agent/tools/create_invite.js +0 -20
  44. package/lib/agent/tools/create_invite.js.map +0 -1
  45. package/lib/agent/tools/list_admins.js +0 -26
  46. package/lib/agent/tools/list_admins.js.map +0 -1
  47. package/lib/agent/tools/member_count.js +0 -18
  48. package/lib/agent/tools/member_count.js.map +0 -1
  49. package/lib/agent/tools/pin_message.js +0 -21
  50. package/lib/agent/tools/pin_message.js.map +0 -1
  51. package/lib/agent/tools/react.js +0 -20
  52. package/lib/agent/tools/react.js.map +0 -1
  53. package/lib/agent/tools/send_poll.js +0 -32
  54. package/lib/agent/tools/send_poll.js.map +0 -1
  55. package/lib/agent/tools/send_sticker.js +0 -19
  56. package/lib/agent/tools/send_sticker.js.map +0 -1
  57. package/lib/agent/tools/set_description.js +0 -19
  58. package/lib/agent/tools/set_description.js.map +0 -1
  59. package/lib/agent/tools/set_permissions.js +0 -34
  60. package/lib/agent/tools/set_permissions.js.map +0 -1
  61. package/lib/agent/tools/unpin_message.js +0 -21
  62. package/lib/agent/tools/unpin_message.js.map +0 -1
  63. package/lib/src/adapter.js +0 -58
  64. package/lib/src/adapter.js.map +0 -1
  65. package/lib/src/endpoint.js +0 -1046
  66. package/lib/src/endpoint.js.map +0 -1
  67. package/lib/src/index.js +0 -215
  68. package/lib/src/index.js.map +0 -1
  69. package/lib/src/platform-permit.js.map +0 -1
  70. package/lib/src/segment-mapper.js +0 -2
  71. package/lib/src/segment-mapper.js.map +0 -1
  72. package/lib/src/telegram-agent-deps.js +0 -10
  73. package/lib/src/telegram-agent-deps.js.map +0 -1
  74. package/lib/src/types.js +0 -2
  75. package/lib/src/types.js.map +0 -1
  76. package/plugin.yml +0 -3
  77. package/src/adapter.ts +0 -66
  78. package/src/segment-mapper.ts +0 -1
  79. package/src/types.ts +0 -32
package/src/endpoint.ts CHANGED
@@ -1,1188 +1,424 @@
1
1
  /**
2
- * Telegram Endpoint 实现
2
+ * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
3
3
  */
4
- import { Telegraf, type Context as TelegrafContext } from "telegraf";
5
- import type { Message as TelegramMessage, Update, MessageEntity, ChatMember } from "telegraf/types";
4
+ import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { MessageGateway } from '@zhin.js/core/runtime';
6
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
9
+ import { runTelegramPollLoop } from './polling.js';
10
+ import { normalizeTelegramChatMember } from './platform-permit.js';
6
11
  import {
7
- Endpoint,
8
- Message,
9
- SendOptions,
10
- SendContent,
11
- MessageSegment,
12
- segment,
13
- expandInteractiveSegmentsInContent,
14
- type QuotedMessagePayload,} from 'zhin.js';
15
- import type { TelegramEndpointConfig, TelegramSenderInfo } from "./types.js";
16
- import type { TelegramAdapter } from "./adapter.js";
17
- import { normalizeTelegramChatMember } from "./platform-permit.js";
18
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
19
-
20
- export class TelegramEndpoint extends Telegraf implements Endpoint<TelegramEndpointConfig, TelegramMessage> {
21
- $connected: boolean = false;
22
- /** message_id -> chat_id,用于在仅有 message_id 的场景执行 reaction 操作 */
23
- private readonly messageChatMap = new Map<string, string>();
24
- /** reply_to_message 解析结果缓存,供 $getMsg 使用 */
25
- private readonly quotedPayloadCache = new Map<string, QuotedMessagePayload>();
26
-
27
- get pluginLogger() {
28
- return this.adapter.plugin.logger;
29
- }
30
-
31
- get $id() {
32
- return this.$config.name;
33
- }
12
+ botApiUrl,
13
+ buildWebhookUrl,
14
+ formatCallbackContent,
15
+ formatInboundContent,
16
+ formatOutboundActions,
17
+ resolveChannel,
18
+ senderDisplayName,
19
+ type ResolvedTelegramConfig,
20
+ type TelegramCallbackQuery,
21
+ type TelegramChatMember,
22
+ type TelegramMessage,
23
+ type TelegramUpdate,
24
+ } from './protocol.js';
25
+ import { registerTelegramAgentEndpoint } from './telegram-agent-deps.js';
26
+ import { registerTelegramWebhookRoutes } from './webhook.js';
27
+
28
+ const logger = getLogger('telegram');
29
+
30
+ const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
31
+ const CHAT_MEMBER_CACHE_MAX = 2_000;
32
+
33
+ interface ChatMemberPermit {
34
+ readonly at: number;
35
+ readonly role?: string;
36
+ readonly permissions: string[];
37
+ }
34
38
 
35
- constructor(public adapter: TelegramAdapter, public $config: TelegramEndpointConfig) {
36
- super($config.token);
37
- }
39
+ export type TelegramFetch = (
40
+ url: string,
41
+ init?: {
42
+ readonly method?: string;
43
+ readonly headers?: Record<string, string>;
44
+ readonly body?: string;
45
+ readonly signal?: AbortSignal;
46
+ },
47
+ ) => Promise<{
48
+ readonly ok: boolean;
49
+ readonly status: number;
50
+ text(): Promise<string>;
51
+ json(): Promise<unknown>;
52
+ }>;
53
+
54
+ export interface TelegramEndpointOptions {
55
+ readonly id: CapabilityId;
56
+ readonly gateway: MessageGateway;
57
+ readonly config: ResolvedTelegramConfig;
58
+ readonly http?: HttpHost;
59
+ readonly fetch?: TelegramFetch;
60
+ }
38
61
 
39
- async $connect(): Promise<void> {
40
- try {
41
- // Set up message handler
42
- this.on("message", async (ctx) => {
43
- await this.handleTelegramMessage(ctx);
44
- });
62
+ interface TelegramApiOk<T> {
63
+ readonly ok: true;
64
+ readonly result: T;
65
+ }
45
66
 
46
- // Set up callback query handler (for inline buttons)
47
- this.on("callback_query", async (ctx) => {
48
- if (ctx.callbackQuery && "data" in ctx.callbackQuery) {
49
- try {
50
- await ctx.answerCbQuery();
51
- } catch {
52
- // already answered
53
- }
54
- const message = this.$formatCallbackQuery(ctx);
55
- this.adapter.emit("message.receive", message);
56
- this.pluginLogger.debug(
57
- `${this.$config.name} recv callback ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`
58
- );
59
- }
60
- });
67
+ interface TelegramApiErr {
68
+ readonly ok: false;
69
+ readonly description?: string;
70
+ readonly error_code?: number;
71
+ }
61
72
 
62
- // Start bot with polling or webhook
63
- if (this.$config.polling !== false) {
64
- // Use polling by default
65
- await this.launch({
66
- allowedUpdates: this.$config.allowedUpdates as any,
67
- });
68
- } else if (this.$config.webhook) {
69
- // Use webhook
70
- const { domain, path = "/telegram-webhook", port } = this.$config.webhook;
71
- await this.launch({
72
- webhook: {
73
- domain,
74
- port,
75
- hookPath: path,
76
- },
77
- allowedUpdates: this.$config.allowedUpdates as any,
78
- });
79
- } else {
80
- throw new Error("Either polling must be enabled or webhook configuration must be provided");
81
- }
73
+ export class TelegramEndpoint implements EndpointInstance {
74
+ readonly #options: TelegramEndpointOptions;
75
+ readonly #fetch: TelegramFetch;
76
+ #pollAbort?: AbortController;
77
+ #pollPromise?: Promise<void>;
78
+ #routeReleases: HttpRouteRegistration[] = [];
79
+ #open = false;
80
+ #started = false;
81
+ #unregisterAgent?: () => void;
82
+ #updateOffset = 0;
83
+ #botUserId?: number;
84
+ #botUsername?: string;
85
+ readonly #chatMemberCache = new Map<string, ChatMemberPermit>();
82
86
 
83
- this.$connected = true;
84
- const me = await this.telegram.getMe();
85
- this.pluginLogger.info(
86
- `Telegram endpoint ${this.$config.name} connected successfully as @${me.username}`
87
- );
88
- } catch (error) {
89
- this.pluginLogger.error("Failed to connect Telegram bot:", error);
90
- this.$connected = false;
91
- throw error;
92
- }
87
+ constructor(options: TelegramEndpointOptions) {
88
+ this.#options = options;
89
+ this.#fetch = options.fetch ?? globalThis.fetch;
93
90
  }
94
91
 
95
- async $disconnect(): Promise<void> {
96
- try {
97
- (this as unknown as import('node:events').EventEmitter).removeAllListeners();
98
- this.chatMemberCache.clear();
99
- await this.stop();
100
- this.$connected = false;
101
- this.pluginLogger.info(`Telegram endpoint ${this.$config.name} disconnected`);
102
- } catch (error) {
103
- this.pluginLogger.error("Error disconnecting Telegram bot:", error);
104
- throw error;
105
- }
92
+ /** Used by webhook handler. */
93
+ get isOpen(): boolean {
94
+ return this.#open;
106
95
  }
107
96
 
108
- private static readonly CHAT_MEMBER_CACHE_TTL_MS = 60_000;
109
- private static readonly CHAT_MEMBER_CACHE_MAX = 2_000;
110
- private chatMemberCache = new Map<string, { at: number; role?: string; permissions: string[] }>();
111
-
112
- private sweepChatMemberCache(now: number): void {
113
- const ttl = TelegramEndpoint.CHAT_MEMBER_CACHE_TTL_MS;
114
- for (const [key, entry] of this.chatMemberCache) {
115
- if (now - entry.at >= ttl) this.chatMemberCache.delete(key);
116
- }
117
- if (this.chatMemberCache.size > TelegramEndpoint.CHAT_MEMBER_CACHE_MAX) {
118
- const excess = this.chatMemberCache.size - TelegramEndpoint.CHAT_MEMBER_CACHE_MAX;
119
- let removed = 0;
120
- for (const [key] of this.chatMemberCache) {
121
- if (removed >= excess) break;
122
- this.chatMemberCache.delete(key);
123
- removed++;
124
- }
125
- }
97
+ get config(): ResolvedTelegramConfig {
98
+ return this.#options.config;
126
99
  }
127
100
 
128
- private async enrichGroupSender(
129
- message: Message<TelegramMessage>,
130
- msg: TelegramMessage,
131
- ): Promise<void> {
132
- if (message.$channel.type !== "group" || !msg.from?.id) return;
133
- const chatId = Number(msg.chat.id);
134
- const userId = msg.from.id;
135
- const key = `${chatId}:${userId}`;
136
- const now = Date.now();
137
- this.sweepChatMemberCache(now);
138
- const cached = this.chatMemberCache.get(key);
139
- if (cached && now - cached.at < TelegramEndpoint.CHAT_MEMBER_CACHE_TTL_MS) {
140
- message.$sender.role = cached.role;
141
- message.$sender.permissions = cached.permissions;
142
- return;
143
- }
144
- try {
145
- const member = await this.getChatMember(chatId, userId);
146
- const normalized = normalizeTelegramChatMember(member as {
147
- status?: string;
148
- can_restrict_members?: boolean;
149
- can_pin_messages?: boolean;
150
- can_delete_messages?: boolean;
151
- can_manage_chat?: boolean;
152
- });
153
- this.chatMemberCache.set(key, { at: now, ...normalized });
154
- message.$sender.role = normalized.role;
155
- message.$sender.permissions = normalized.permissions;
156
- } catch {
157
- // 保守拒绝:无角色快照
158
- }
159
- }
160
-
161
- private async handleTelegramMessage(ctx: TelegrafContext): Promise<void> {
162
- if (!ctx.message) return;
163
-
164
- const message = this.$formatMessage(ctx.message as TelegramMessage);
165
- await this.enrichGroupSender(message, ctx.message as TelegramMessage);
166
- this.adapter.emit("message.receive", message);
167
- this.pluginLogger.debug(
168
- `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`
169
- );
170
- }
171
-
172
- $formatMessage(msg: TelegramMessage): Message<TelegramMessage> {
173
- // Determine channel type
174
- const channelType = msg.chat.type === "private" ? "private" : "group";
175
- const channelId = msg.chat.id.toString();
176
-
177
- // Parse message content
178
- const wire = this.parseMessageContent(msg);
179
- const quoteId = Message.quoteIdFromContent(wire);
180
- Message.alignReplySegments(wire, quoteId);
181
- const content = toCanonicalSegments(wire);
182
-
183
- const result = Message.from(msg, {
184
- $id: msg.message_id.toString(),
185
- $adapter: "telegram",
186
- $endpoint: this.$config.name,
187
- $sender: {
188
- id: msg.from?.id.toString() || "",
189
- name: msg.from?.username || msg.from?.first_name || "Unknown",
190
- },
191
- $channel: {
192
- id: channelId,
193
- type: channelType,
194
- },
195
- $content: content,
196
- $quote_id: quoteId,
197
- $raw: "text" in msg ? msg.text || "" : "",
198
- $timestamp: msg.date * 1000,
199
- $recall: async () => {
200
- try {
201
- await this.telegram.deleteMessage(parseInt(channelId), parseInt(result.$id));
202
- } catch (error) {
203
- this.pluginLogger.error("Error recalling Telegram message:", error);
204
- throw error;
205
- }
206
- },
207
- $reply: async (
208
- content: SendContent,
209
- quote?: boolean | string
210
- ): Promise<string> => {
211
- if (!Array.isArray(content)) content = [content];
212
- // Handle reply
213
- if (quote) {
214
- const replyToMessageId = typeof quote === "boolean" ? result.$id : quote;
215
- content.unshift({ type: "reply", data: { id: replyToMessageId } });
216
- }
217
-
218
- return await this.adapter.sendMessage({
219
- context: "telegram",
220
- endpoint: this.$config.name,
221
- id: channelId,
222
- type: channelType,
223
- content: content,
224
- });
225
- },
226
- });
227
-
228
- this.messageChatMap.set(result.$id, channelId);
229
-
230
- return result;
101
+ get allowedUpdates(): readonly string[] {
102
+ return this.#options.config.allowedUpdates;
231
103
  }
232
104
 
233
- async $getMsg(messageId: string): Promise<QuotedMessagePayload> {
234
- const hit = this.quotedPayloadCache.get(messageId);
235
- if (!hit) {
236
- throw new Error(`Telegram quoted message ${messageId} is not cached`);
237
- }
238
- return hit;
105
+ getUpdateOffset(): number {
106
+ return this.#updateOffset;
239
107
  }
240
108
 
241
- private cacheQuotedTelegramMessage(msg: TelegramMessage): void {
242
- const segments = this.parseMessageContent(msg);
243
- const payload: QuotedMessagePayload = {
244
- messageId: msg.message_id.toString(),
245
- sender: msg.from
246
- ? {
247
- id: msg.from.id.toString(),
248
- name: msg.from.username || msg.from.first_name,
249
- }
250
- : undefined,
251
- content: segments,
252
- raw: "text" in msg ? msg.text : undefined,
253
- time: msg.date,
254
- };
255
- this.quotedPayloadCache.set(payload.messageId, payload);
109
+ setUpdateOffset(offset: number): void {
110
+ this.#updateOffset = offset;
256
111
  }
257
112
 
258
- private $formatCallbackQuery(ctx: TelegrafContext): Message<any> {
259
- if (!ctx.callbackQuery || !("data" in ctx.callbackQuery)) {
260
- throw new Error("Invalid callback query");
261
- }
262
-
263
- const query = ctx.callbackQuery;
264
- const msg = query.message;
265
-
266
- const channelType = msg && "chat" in msg && msg.chat.type === "private" ? "private" : "group";
267
- const channelId = msg && "chat" in msg ? msg.chat.id.toString() : query.from.id.toString();
268
-
269
- const result = Message.from(query, {
270
- $id: query.id,
271
- $adapter: "telegram",
272
- $endpoint: this.$config.name,
273
- $sender: {
274
- id: query.from.id.toString(),
275
- name: query.from.username || query.from.first_name,
276
- },
277
- $channel: {
278
- id: channelId,
279
- type: channelType,
280
- },
281
- $content: [{
282
- type: "action",
283
- data: {
284
- id: query.id,
285
- payload: query.data,
286
- sourceMessageId: msg && "message_id" in msg ? String(msg.message_id) : undefined,
287
- },
288
- }],
289
- $raw: query.data,
290
- $timestamp: Date.now(),
291
- $recall: async () => {
292
- // Callback queries cannot be recalled
293
- },
294
- $reply: async (content: SendContent): Promise<string> => {
295
- if (!Array.isArray(content)) content = [content];
296
- const sentMsg = await this.sendContentToChat(
297
- parseInt(channelId),
298
- content
299
- );
300
- return sentMsg.message_id.toString();
301
- },
302
- });
303
-
304
- return result;
305
- }
306
-
307
- private parseMessageContent(msg: TelegramMessage): MessageSegment[] {
308
- const segments: MessageSegment[] = [];
309
-
310
- // Handle text messages
311
- if ("text" in msg && msg.text) {
312
- // Check for reply
313
- if (msg.reply_to_message) {
314
- this.cacheQuotedTelegramMessage(msg.reply_to_message);
315
- const replyId = msg.reply_to_message.message_id.toString();
316
- segments.push({
317
- type: "reply",
318
- data: { id: replyId, message_id: replyId },
319
- });
320
- }
321
-
322
- // Parse text with entities
323
- if (msg.entities && msg.entities.length > 0) {
324
- segments.push(...this.parseTextWithEntities(msg.text, msg.entities));
325
- } else {
326
- segments.push({ type: "text", data: { text: msg.text } });
327
- }
328
- }
329
-
330
- // Handle photo
331
- if ("photo" in msg && msg.photo) {
332
- const largestPhoto = msg.photo[msg.photo.length - 1];
333
- segments.push({
334
- type: "image",
335
- data: {
336
- file_id: largestPhoto.file_id,
337
- file_unique_id: largestPhoto.file_unique_id,
338
- width: largestPhoto.width,
339
- height: largestPhoto.height,
340
- file_size: largestPhoto.file_size,
341
- },
342
- });
343
- if (msg.caption) {
344
- segments.push({ type: "text", data: { text: msg.caption } });
345
- }
346
- }
347
-
348
- // Handle video
349
- if ("video" in msg && msg.video) {
350
- segments.push({
351
- type: "video",
352
- data: {
353
- file_id: msg.video.file_id,
354
- file_unique_id: msg.video.file_unique_id,
355
- width: msg.video.width,
356
- height: msg.video.height,
357
- duration: msg.video.duration,
358
- file_size: msg.video.file_size,
359
- },
360
- });
361
- if (msg.caption) {
362
- segments.push({ type: "text", data: { text: msg.caption } });
363
- }
364
- }
365
-
366
- // Handle audio
367
- if ("audio" in msg && msg.audio) {
368
- segments.push({
369
- type: "audio",
370
- data: {
371
- file_id: msg.audio.file_id,
372
- file_unique_id: msg.audio.file_unique_id,
373
- duration: msg.audio.duration,
374
- performer: msg.audio.performer,
375
- title: msg.audio.title,
376
- file_size: msg.audio.file_size,
377
- },
378
- });
379
- }
380
-
381
- // Handle voice
382
- if ("voice" in msg && msg.voice) {
383
- segments.push({
384
- type: "voice",
385
- data: {
386
- file_id: msg.voice.file_id,
387
- file_unique_id: msg.voice.file_unique_id,
388
- duration: msg.voice.duration,
389
- file_size: msg.voice.file_size,
390
- },
391
- });
392
- }
393
-
394
- // Handle document
395
- if ("document" in msg && msg.document) {
396
- segments.push({
397
- type: "file",
398
- data: {
399
- file_id: msg.document.file_id,
400
- file_unique_id: msg.document.file_unique_id,
401
- file_name: msg.document.file_name,
402
- mime_type: msg.document.mime_type,
403
- file_size: msg.document.file_size,
404
- },
405
- });
406
- }
407
-
408
- // Handle sticker
409
- if ("sticker" in msg && msg.sticker) {
410
- segments.push({
411
- type: "sticker",
412
- data: {
413
- file_id: msg.sticker.file_id,
414
- file_unique_id: msg.sticker.file_unique_id,
415
- width: msg.sticker.width,
416
- height: msg.sticker.height,
417
- is_animated: msg.sticker.is_animated,
418
- is_video: msg.sticker.is_video,
419
- emoji: msg.sticker.emoji,
420
- },
421
- });
422
- }
423
-
424
- // Handle location
425
- if ("location" in msg && msg.location) {
426
- segments.push({
427
- type: "location",
428
- data: {
429
- longitude: msg.location.longitude,
430
- latitude: msg.location.latitude,
431
- },
432
- });
433
- }
434
-
435
- return segments.length > 0
436
- ? segments
437
- : [{ type: "text", data: { text: "" } }];
438
- }
439
-
440
- private parseTextWithEntities(
441
- text: string,
442
- entities: MessageEntity[]
443
- ): MessageSegment[] {
444
- const segments: MessageSegment[] = [];
445
- let lastOffset = 0;
446
-
447
- for (const entity of entities) {
448
- // Add text before entity
449
- if (entity.offset > lastOffset) {
450
- const beforeText = text.slice(lastOffset, entity.offset);
451
- if (beforeText) {
452
- segments.push({ type: "text", data: { text: beforeText } });
113
+ async start(): Promise<void> {
114
+ if (this.#started) return;
115
+ this.#started = true;
116
+ try {
117
+ this.#unregisterAgent = registerTelegramAgentEndpoint(this.#options.config.name, this);
118
+ const me = await this.callApi<{ id?: number; username?: string; first_name?: string }>('getMe');
119
+ this.#botUserId = me.id;
120
+ this.#botUsername = me.username;
121
+
122
+ if (this.#options.config.mode === 'webhook') {
123
+ if (!this.#options.http) {
124
+ throw new TypeError('Telegram webhook mode requires httpHostToken');
453
125
  }
126
+ this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
127
+ const webhook = this.#options.config.webhook!;
128
+ const url = buildWebhookUrl(webhook);
129
+ await this.callApi('setWebhook', {
130
+ url,
131
+ allowed_updates: this.#options.config.allowedUpdates,
132
+ ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
133
+ });
134
+ logger.info(formatCompact({
135
+ op: 'connect',
136
+ endpoint: this.#options.config.name,
137
+ mode: 'webhook',
138
+ path: webhook.path,
139
+ username: me.username,
140
+ }));
141
+ return;
454
142
  }
455
143
 
456
- const entityText = text.slice(
457
- entity.offset,
458
- entity.offset + entity.length
459
- );
460
-
461
- switch (entity.type) {
462
- case "mention":
463
- case "text_mention":
464
- segments.push({
465
- type: "at",
466
- data: {
467
- id: ("user" in entity && entity.user?.id.toString()) || entityText.slice(1),
468
- name: ("user" in entity && entity.user?.username) || entityText,
469
- text: entityText,
470
- },
471
- });
472
- break;
473
-
474
- case "url":
475
- case "text_link":
476
- segments.push({
477
- type: "link",
478
- data: {
479
- url: ("url" in entity && entity.url) || entityText,
480
- text: entityText,
481
- },
482
- });
483
- break;
484
-
485
- case "hashtag":
486
- segments.push({
487
- type: "text",
488
- data: { text: entityText },
489
- });
490
- break;
491
-
492
- case "bold":
493
- case "italic":
494
- case "code":
495
- case "pre":
496
- case "underline":
497
- case "strikethrough":
498
- segments.push({
499
- type: "text",
500
- data: { text: entityText, format: entity.type },
501
- });
502
- break;
503
-
504
- default:
505
- segments.push({ type: "text", data: { text: entityText } });
506
- }
507
-
508
- lastOffset = entity.offset + entity.length;
509
- }
510
-
511
- // Add remaining text
512
- if (lastOffset < text.length) {
513
- const remainingText = text.slice(lastOffset);
514
- if (remainingText) {
515
- segments.push({ type: "text", data: { text: remainingText } });
516
- }
517
- }
518
-
519
- return segments;
520
- }
521
-
522
- async $sendMessage(options: SendOptions): Promise<string> {
523
- try {
524
- const chatId = parseInt(options.id);
525
- const canonical = expandInteractiveSegmentsInContent(options.content);
526
- const wire = fromCanonicalSegments(canonical);
527
- const result = await this.sendContentToChat(chatId, wire);
528
- this.messageChatMap.set(result.message_id.toString(), options.id);
529
- this.pluginLogger.debug(
530
- `${this.$config.name} send ${options.type}(${options.id}): ${segment.raw(options.content)}`
531
- );
532
- return result.message_id.toString();
144
+ await this.callApi('deleteWebhook', { drop_pending_updates: false });
145
+ this.#pollAbort = new AbortController();
146
+ this.#pollPromise = runTelegramPollLoop(this, this.#pollAbort.signal);
147
+ logger.info(formatCompact({
148
+ op: 'connect',
149
+ endpoint: this.#options.config.name,
150
+ mode: 'polling',
151
+ username: me.username,
152
+ }));
533
153
  } catch (error) {
534
- this.pluginLogger.error("Failed to send Telegram message:", error);
154
+ await this.stop();
155
+ logger.error('Failed to connect Telegram bot:', error);
535
156
  throw error;
536
157
  }
537
158
  }
538
159
 
539
- async $editMessage(options: import('zhin.js').EditMessageOptions): Promise<void> {
540
- const chatId = parseInt(options.id);
541
- const messageId = parseInt(options.messageId);
542
- const content = Array.isArray(options.content) ? options.content : [options.content];
543
- let textContent = "";
544
- let keyboard: { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } | undefined;
545
- for (const seg of content) {
546
- if (typeof seg === "string") {
547
- textContent += seg;
548
- continue;
549
- }
550
- if (seg.type === "text") textContent += seg.data.text || "";
551
- if (seg.type === "keyboard") {
552
- keyboard = {
553
- inline_keyboard: (seg.data.rows ?? []).map((row: Array<{ label: string; payload: string; disabled?: boolean }>) =>
554
- row.map((btn) => ({
555
- text: btn.label,
556
- callback_data: String(btn.payload).slice(0, 64),
557
- })),
558
- ),
559
- };
560
- }
561
- }
562
- await this.telegram.editMessageText(
563
- chatId,
564
- messageId,
565
- undefined,
566
- textContent.trim() || " ",
567
- keyboard ? { reply_markup: keyboard } : {},
568
- );
160
+ open(): void {
161
+ this.#open = true;
569
162
  }
570
163
 
571
- private async sendContentToChat(
572
- chatId: number,
573
- content: SendContent,
574
- extraOptions: any = {}
575
- ): Promise<TelegramMessage> {
576
- if (!Array.isArray(content)) content = [content];
577
-
578
- let textContent = "";
579
- let hasMedia = false;
580
-
581
- for (const segment of content) {
582
- if (typeof segment === "string") {
583
- textContent += segment;
584
- continue;
585
- }
586
-
587
- const { type, data } = segment;
588
-
589
- switch (type) {
590
- case "text":
591
- textContent += data.text || "";
592
- break;
593
-
594
- case "at":
595
- if (data.id) {
596
- textContent += `@${data.name || data.id}`;
597
- }
598
- break;
599
-
600
- case "image":
601
- hasMedia = true;
602
- if (data.file_id) {
603
- // Send by file_id
604
- return await this.telegram.sendPhoto(chatId, data.file_id, {
605
- caption: textContent || undefined,
606
- ...extraOptions,
607
- });
608
- } else if (data.url) {
609
- // Send by URL
610
- return await this.telegram.sendPhoto(chatId, data.url, {
611
- caption: textContent || undefined,
612
- ...extraOptions,
613
- });
614
- } else if (data.file) {
615
- // Send by file path
616
- return await this.telegram.sendPhoto(
617
- chatId,
618
- { source: data.file },
619
- {
620
- caption: textContent || undefined,
621
- ...extraOptions,
622
- }
623
- );
624
- }
625
- break;
626
-
627
- case "video":
628
- hasMedia = true;
629
- if (data.file_id) {
630
- return await this.telegram.sendVideo(chatId, data.file_id, {
631
- caption: textContent || undefined,
632
- ...extraOptions,
633
- });
634
- } else if (data.url) {
635
- return await this.telegram.sendVideo(chatId, data.url, {
636
- caption: textContent || undefined,
637
- ...extraOptions,
638
- });
639
- } else if (data.file) {
640
- return await this.telegram.sendVideo(
641
- chatId,
642
- { source: data.file },
643
- {
644
- caption: textContent || undefined,
645
- ...extraOptions,
646
- }
647
- );
648
- }
649
- break;
650
-
651
- case "audio":
652
- hasMedia = true;
653
- if (data.file_id) {
654
- return await this.telegram.sendAudio(chatId, data.file_id, {
655
- caption: textContent || undefined,
656
- ...extraOptions,
657
- });
658
- } else if (data.url) {
659
- return await this.telegram.sendAudio(chatId, data.url, {
660
- caption: textContent || undefined,
661
- ...extraOptions,
662
- });
663
- } else if (data.file) {
664
- return await this.telegram.sendAudio(
665
- chatId,
666
- { source: data.file },
667
- {
668
- caption: textContent || undefined,
669
- ...extraOptions,
670
- }
671
- );
672
- }
673
- break;
674
-
675
- case "voice":
676
- hasMedia = true;
677
- if (data.file_id) {
678
- return await this.telegram.sendVoice(chatId, data.file_id, {
679
- caption: textContent || undefined,
680
- ...extraOptions,
681
- });
682
- } else if (data.url) {
683
- return await this.telegram.sendVoice(chatId, data.url, {
684
- caption: textContent || undefined,
685
- ...extraOptions,
686
- });
687
- } else if (data.file) {
688
- return await this.telegram.sendVoice(
689
- chatId,
690
- { source: data.file },
691
- {
692
- caption: textContent || undefined,
693
- ...extraOptions,
694
- }
695
- );
696
- }
697
- break;
698
-
699
- case "file":
700
- hasMedia = true;
701
- if (data.file_id) {
702
- return await this.telegram.sendDocument(chatId, data.file_id, {
703
- caption: textContent || undefined,
704
- ...extraOptions,
705
- });
706
- } else if (data.url) {
707
- return await this.telegram.sendDocument(chatId, data.url, {
708
- caption: textContent || undefined,
709
- ...extraOptions,
710
- });
711
- } else if (data.file) {
712
- return await this.telegram.sendDocument(
713
- chatId,
714
- { source: data.file },
715
- {
716
- caption: textContent || undefined,
717
- ...extraOptions,
718
- }
719
- );
720
- }
721
- break;
722
-
723
- case "sticker":
724
- if (data.file_id) {
725
- hasMedia = true;
726
- return await this.telegram.sendSticker(chatId, data.file_id, extraOptions);
727
- }
728
- break;
729
-
730
- case "location":
731
- return await this.telegram.sendLocation(
732
- chatId,
733
- data.latitude,
734
- data.longitude,
735
- extraOptions
736
- );
737
- case "reply":
738
- return await this.telegram.sendMessage(chatId, data.id, {
739
- reply_parameters: { message_id: parseInt(data.id) },
740
- ...extraOptions,
741
- });
742
-
743
- case "keyboard": {
744
- const rows = (data.rows ?? []).map((row: Array<{ label: string; payload: string; disabled?: boolean }>) =>
745
- row.map((btn) => ({
746
- text: btn.label,
747
- callback_data: String(btn.payload).slice(0, 64),
748
- })),
749
- );
750
- return await this.telegram.sendMessage(chatId, textContent.trim() || " ", {
751
- reply_markup: { inline_keyboard: rows },
752
- ...extraOptions,
753
- });
754
- }
755
-
756
- default:
757
- // Unknown segment type, add as text
758
- textContent += data.text || `[${type}]`;
759
- }
760
- }
761
-
762
- // If no media was sent, send as text message
763
- if (!hasMedia && textContent.trim()) {
764
- return await this.telegram.sendMessage(chatId, textContent.trim(), extraOptions);
765
- }
766
-
767
- // If neither media nor text was sent, this is an error
768
- throw new Error("TelegramEndpoint.$sendMessage: No media or text content to send.");
164
+ close(): void {
165
+ this.#open = false;
769
166
  }
770
167
 
771
- async $recallMessage(id: string): Promise<void> {
772
- // The Endpoint interface only provides message_id, making recall impossible
773
- // Users should use message.$recall() instead, which has the full context
774
- throw new Error(
775
- "TelegramEndpoint.$recallMessage: Message recall not supported without chat_id. " +
776
- "Use message.$recall() method instead, which contains the required context."
777
- );
168
+ async stop(): Promise<void> {
169
+ this.#open = false;
170
+ this.#pollAbort?.abort();
171
+ try {
172
+ await this.#pollPromise;
173
+ } catch {
174
+ /* poll loop exit */
175
+ }
176
+ for (const release of this.#routeReleases.splice(0)) release();
177
+ this.#unregisterAgent?.();
178
+ this.#unregisterAgent = undefined;
179
+ this.#chatMemberCache.clear();
180
+ this.#started = false;
181
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
182
+ }
183
+
184
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
185
+ const actions = formatOutboundActions(target, payload);
186
+ let lastId = '';
187
+ for (const action of actions) {
188
+ const result = await this.callApi<{ message_id?: number }>(action.method, action.params);
189
+ if (result.message_id != null) lastId = String(result.message_id);
190
+ }
191
+ return lastId || `telegram-${Date.now()}`;
192
+ }
193
+
194
+ /** Test / internal: admit a message when open. */
195
+ admit(msg: TelegramMessage): void {
196
+ if (!this.#open) return;
197
+ const { channelId } = resolveChannel(msg);
198
+ void this.#admitWithSenderRole(msg, channelId).catch((err) => {
199
+ logger.warn(formatCompact({
200
+ op: 'telegram_gateway_receive_failed',
201
+ target: channelId,
202
+ error: err instanceof Error ? err.message : String(err),
203
+ }));
204
+ });
778
205
  }
779
206
 
780
- private resolveTelegramMessageRef(messageId: string): { chatId: number; msgId: number } | null {
781
- if (messageId.includes(':')) {
782
- const [chatIdRaw, msgIdRaw] = messageId.split(':');
783
- const chatId = Number(chatIdRaw);
784
- const msgId = Number(msgIdRaw);
785
- if (Number.isFinite(chatId) && Number.isFinite(msgId)) {
786
- this.messageChatMap.set(String(msgId), String(chatId));
787
- return { chatId, msgId };
788
- }
789
- }
790
-
791
- const mappedChatId = this.messageChatMap.get(messageId);
792
- if (!mappedChatId) return null;
793
- const chatId = Number(mappedChatId);
794
- const msgId = Number(messageId);
795
- if (!Number.isFinite(chatId) || !Number.isFinite(msgId)) return null;
796
- return { chatId, msgId };
207
+ async #admitWithSenderRole(msg: TelegramMessage, channelId: string): Promise<void> {
208
+ const permit = await this.#resolveGroupSenderPermit(msg);
209
+ // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
210
+ const mentioned = this.#isBotMentioned(msg);
211
+ await this.#options.gateway.receive({
212
+ adapter: this.#options.id,
213
+ target: channelId,
214
+ content: formatInboundContent(msg),
215
+ sender: senderDisplayName(msg.from),
216
+ id: String(msg.message_id),
217
+ metadata: Object.freeze({
218
+ endpoint: this.#options.config.name,
219
+ chatType: msg.chat.type,
220
+ userId: msg.from?.id,
221
+ date: msg.date,
222
+ ...(permit?.role ? { senderRole: permit.role } : {}),
223
+ ...(permit?.permissions.length ? { senderPermissions: [...permit.permissions] } : {}),
224
+ ...(mentioned ? { mentioned: true } : {}),
225
+ }),
226
+ });
797
227
  }
798
228
 
799
- async $addReaction(messageId: string, emoji: string): Promise<string | null> {
800
- const ref = this.resolveTelegramMessageRef(messageId);
801
- if (!ref) {
802
- this.pluginLogger.warn(`Telegram Endpoint ${this.$id} 无法根据 message_id=${messageId} 定位 chat_id,跳过 addReaction`);
803
- return null;
804
- }
805
-
806
- try {
807
- await this.setMessageReaction(ref.chatId, ref.msgId, emoji);
808
- return emoji;
809
- } catch (error) {
810
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 添加 reaction 失败:`, error);
811
- return null;
229
+ /** entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
230
+ #isBotMentioned(msg: TelegramMessage): boolean {
231
+ if (!msg.entities?.length) return false;
232
+ for (const entity of msg.entities) {
233
+ if (entity.type === 'text_mention') {
234
+ if (this.#botUserId != null && entity.user?.id === this.#botUserId) return true;
235
+ continue;
236
+ }
237
+ if (entity.type !== 'mention' || !this.#botUsername) continue;
238
+ const slice = (msg.text ?? '').slice(entity.offset, entity.offset + entity.length);
239
+ if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`) return true;
812
240
  }
241
+ return false;
813
242
  }
814
243
 
815
- async $removeReaction(messageId: string, _reactionId: string): Promise<void> {
816
- const ref = this.resolveTelegramMessageRef(messageId);
817
- if (!ref) {
818
- this.pluginLogger.warn(`Telegram Endpoint ${this.$id} 无法根据 message_id=${messageId} 定位 chat_id,跳过 removeReaction`);
819
- return;
820
- }
821
-
244
+ /** 群消息 sender role 解析:getChatMember + 60s 缓存(对齐旧 enrichGroupSender)。 */
245
+ async #resolveGroupSenderPermit(msg: TelegramMessage): Promise<ChatMemberPermit | undefined> {
246
+ if (msg.chat.type === 'private' || !msg.from?.id) return undefined;
247
+ const chatId = Number(msg.chat.id);
248
+ const userId = msg.from.id;
249
+ const key = `${chatId}:${userId}`;
250
+ const now = Date.now();
251
+ this.#sweepChatMemberCache(now);
252
+ const cached = this.#chatMemberCache.get(key);
253
+ if (cached && now - cached.at < CHAT_MEMBER_CACHE_TTL_MS) return cached;
822
254
  try {
823
- await (this.telegram as any).callApi('setMessageReaction', {
824
- chat_id: ref.chatId,
825
- message_id: ref.msgId,
826
- reaction: [],
255
+ const member = await this.callApi<TelegramChatMember>('getChatMember', {
256
+ chat_id: chatId,
257
+ user_id: userId,
827
258
  });
828
- } catch (error) {
829
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 移除 reaction 失败:`, error);
259
+ const normalized = normalizeTelegramChatMember(member);
260
+ const entry: ChatMemberPermit = { at: now, ...normalized };
261
+ this.#chatMemberCache.set(key, entry);
262
+ return entry;
263
+ } catch {
264
+ // 保守拒绝:无角色快照
265
+ return undefined;
830
266
  }
831
267
  }
832
- // ==================== 群组管理 API ====================
833
268
 
834
- /**
835
- * 踢出用户
836
- * @param chatId 聊天 ID
837
- * @param userId 用户 ID
838
- * @param untilDate 封禁截止时间(Unix 时间戳),0 表示永久
839
- */
840
- async kickMember(chatId: number, userId: number, untilDate?: number): Promise<boolean> {
841
- try {
842
- await this.telegram.banChatMember(chatId, userId, untilDate);
843
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 踢出用户 ${userId} 从聊天 ${chatId}`);
844
- return true;
845
- } catch (error) {
846
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 踢出用户失败:`, error);
847
- throw error;
269
+ #sweepChatMemberCache(now: number): void {
270
+ for (const [key, entry] of this.#chatMemberCache) {
271
+ if (now - entry.at >= CHAT_MEMBER_CACHE_TTL_MS) this.#chatMemberCache.delete(key);
848
272
  }
849
- }
850
-
851
- /**
852
- * 解除封禁
853
- * @param chatId 聊天 ID
854
- * @param userId 用户 ID
855
- */
856
- async unbanMember(chatId: number, userId: number): Promise<boolean> {
857
- try {
858
- await this.telegram.unbanChatMember(chatId, userId, { only_if_banned: true });
859
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 解除用户 ${userId} 的封禁(聊天 ${chatId})`);
860
- return true;
861
- } catch (error) {
862
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 解除封禁失败:`, error);
863
- throw error;
273
+ if (this.#chatMemberCache.size > CHAT_MEMBER_CACHE_MAX) {
274
+ const excess = this.#chatMemberCache.size - CHAT_MEMBER_CACHE_MAX;
275
+ let removed = 0;
276
+ for (const [key] of this.#chatMemberCache) {
277
+ if (removed >= excess) break;
278
+ this.#chatMemberCache.delete(key);
279
+ removed++;
280
+ }
864
281
  }
865
282
  }
866
283
 
867
- /**
868
- * 限制用户权限(禁言等)
869
- * @param chatId 聊天 ID
870
- * @param userId 用户 ID
871
- * @param permissions 权限设置
872
- * @param untilDate 限制截止时间
873
- */
874
- async restrictMember(chatId: number, userId: number, permissions: {
875
- can_send_messages?: boolean;
876
- can_send_media_messages?: boolean;
877
- can_send_polls?: boolean;
878
- can_send_other_messages?: boolean;
879
- can_add_web_page_previews?: boolean;
880
- can_change_info?: boolean;
881
- can_invite_users?: boolean;
882
- can_pin_messages?: boolean;
883
- }, untilDate?: number): Promise<boolean> {
884
- try {
885
- await this.telegram.restrictChatMember(chatId, userId, {
886
- permissions,
887
- until_date: untilDate,
888
- });
889
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 限制用户 ${userId} 权限(聊天 ${chatId})`);
890
- return true;
891
- } catch (error) {
892
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 限制权限失败:`, error);
893
- throw error;
894
- }
284
+ /** Test / internal: admit a callback query when open. */
285
+ admitCallback(query: TelegramCallbackQuery): void {
286
+ if (!this.#open) return;
287
+ const msg = query.message;
288
+ const channelId = msg ? resolveChannel(msg).channelId : String(query.from.id);
289
+ void this.#options.gateway.receive({
290
+ adapter: this.#options.id,
291
+ target: channelId,
292
+ content: formatCallbackContent(query),
293
+ sender: senderDisplayName(query.from),
294
+ id: query.id,
295
+ metadata: Object.freeze({
296
+ endpoint: this.#options.config.name,
297
+ eventType: 'callback_query',
298
+ payload: query.data,
299
+ sourceMessageId: msg ? String(msg.message_id) : undefined,
300
+ }),
301
+ }).catch((err) => {
302
+ logger.warn(formatCompact({
303
+ op: 'telegram_gateway_receive_failed',
304
+ target: channelId,
305
+ error: err instanceof Error ? err.message : String(err),
306
+ }));
307
+ });
895
308
  }
896
309
 
897
- /**
898
- * 禁言用户
899
- * @param chatId 聊天 ID
900
- * @param userId 用户 ID
901
- * @param duration 禁言时长(秒),0 表示解除禁言
902
- */
903
- async muteMember(chatId: number, userId: number, duration: number = 600): Promise<boolean> {
904
- try {
905
- if (duration === 0) {
906
- // 解除禁言 - 恢复发送消息权限
907
- await this.telegram.restrictChatMember(chatId, userId, {
908
- permissions: {
909
- can_send_messages: true,
910
- can_send_audios: true,
911
- can_send_documents: true,
912
- can_send_photos: true,
913
- can_send_videos: true,
914
- can_send_video_notes: true,
915
- can_send_voice_notes: true,
916
- can_send_polls: true,
917
- can_send_other_messages: true,
918
- can_add_web_page_previews: true,
919
- },
920
- });
921
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 解除用户 ${userId} 禁言(聊天 ${chatId})`);
922
- } else {
923
- const untilDate = Math.floor(Date.now() / 1000) + duration;
924
- await this.telegram.restrictChatMember(chatId, userId, {
925
- permissions: {
926
- can_send_messages: false,
927
- can_send_audios: false,
928
- can_send_documents: false,
929
- can_send_photos: false,
930
- can_send_videos: false,
931
- can_send_video_notes: false,
932
- can_send_voice_notes: false,
933
- can_send_polls: false,
934
- can_send_other_messages: false,
935
- can_add_web_page_previews: false,
936
- },
937
- until_date: untilDate,
938
- });
939
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 禁言用户 ${userId} ${duration}秒(聊天 ${chatId})`);
940
- }
941
- return true;
942
- } catch (error) {
943
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 禁言操作失败:`, error);
944
- throw error;
310
+ /** Used by webhook / polling handlers. */
311
+ handleUpdate(update: TelegramUpdate): void {
312
+ if (update.message) {
313
+ this.admit(update.message);
314
+ return;
945
315
  }
946
- }
947
-
948
- /**
949
- * 提升/降级管理员
950
- * @param chatId 聊天 ID
951
- * @param userId 用户 ID
952
- * @param promote 是否提升为管理员
953
- */
954
- async setAdmin(chatId: number, userId: number, promote: boolean = true): Promise<boolean> {
955
- try {
956
- if (promote) {
957
- await this.telegram.promoteChatMember(chatId, userId, {
958
- can_manage_chat: true,
959
- can_delete_messages: true,
960
- can_manage_video_chats: true,
961
- can_restrict_members: true,
962
- can_promote_members: false,
963
- can_change_info: true,
964
- can_invite_users: true,
965
- can_pin_messages: true,
966
- });
967
- } else {
968
- await this.telegram.promoteChatMember(chatId, userId, {
969
- can_manage_chat: false,
970
- can_delete_messages: false,
971
- can_manage_video_chats: false,
972
- can_restrict_members: false,
973
- can_promote_members: false,
974
- can_change_info: false,
975
- can_invite_users: false,
976
- can_pin_messages: false,
316
+ if (update.callback_query) {
317
+ const query = update.callback_query;
318
+ if (query.data) {
319
+ void this.callApi('answerCallbackQuery', { callback_query_id: query.id }).catch(() => {
320
+ /* already answered */
977
321
  });
978
322
  }
979
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} ${promote ? '提升' : '降级'}用户 ${userId} 为管理员(聊天 ${chatId})`);
980
- return true;
981
- } catch (error) {
982
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 设置管理员失败:`, error);
983
- throw error;
323
+ this.admitCallback(query);
984
324
  }
985
325
  }
986
326
 
987
- /**
988
- * 设置聊天标题
989
- * @param chatId 聊天 ID
990
- * @param title 新标题
991
- */
992
- async setChatTitle(chatId: number, title: string): Promise<boolean> {
327
+ async callApi<T = unknown>(
328
+ method: string,
329
+ params: Record<string, unknown> = {},
330
+ signal?: AbortSignal,
331
+ ): Promise<T> {
332
+ const url = botApiUrl(this.#options.config, method);
333
+ const response = await this.#fetch(url, {
334
+ method: 'POST',
335
+ headers: { 'Content-Type': 'application/json' },
336
+ body: JSON.stringify(params),
337
+ signal,
338
+ });
339
+ const text = await response.text();
340
+ let body: TelegramApiOk<T> | TelegramApiErr;
993
341
  try {
994
- await this.telegram.setChatTitle(chatId, title);
995
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 设置聊天 ${chatId} 标题为 "${title}"`);
996
- return true;
997
- } catch (error) {
998
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 设置标题失败:`, error);
999
- throw error;
342
+ body = JSON.parse(text) as TelegramApiOk<T> | TelegramApiErr;
343
+ } catch {
344
+ throw new Error(`Telegram API ${method} invalid JSON (${response.status}): ${text.slice(0, 200)}`);
1000
345
  }
1001
- }
1002
-
1003
- /**
1004
- * 设置聊天描述
1005
- * @param chatId 聊天 ID
1006
- * @param description 新描述
1007
- */
1008
- async setChatDescription(chatId: number, description: string): Promise<boolean> {
1009
- try {
1010
- await this.telegram.setChatDescription(chatId, description);
1011
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 设置聊天 ${chatId} 描述`);
1012
- return true;
1013
- } catch (error) {
1014
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 设置描述失败:`, error);
1015
- throw error;
346
+ if (!body.ok) {
347
+ throw new Error(
348
+ `Telegram API ${method} failed (${body.error_code ?? response.status}): ${body.description ?? text}`,
349
+ );
1016
350
  }
351
+ return body.result;
1017
352
  }
1018
353
 
1019
- /**
1020
- * 置顶消息
1021
- * @param chatId 聊天 ID
1022
- * @param messageId 消息 ID
1023
- */
354
+ // ── Agent tool surface ──────────────────────────────────────────────
355
+
1024
356
  async pinMessage(chatId: number, messageId: number): Promise<boolean> {
1025
- try {
1026
- await this.telegram.pinChatMessage(chatId, messageId);
1027
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 置顶消息 ${messageId}(聊天 ${chatId})`);
1028
- return true;
1029
- } catch (error) {
1030
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 置顶消息失败:`, error);
1031
- throw error;
1032
- }
357
+ await this.callApi('pinChatMessage', { chat_id: chatId, message_id: messageId });
358
+ return true;
1033
359
  }
1034
360
 
1035
- /**
1036
- * 取消置顶消息
1037
- * @param chatId 聊天 ID
1038
- * @param messageId 消息 ID(可选,不提供则取消所有置顶)
1039
- */
1040
361
  async unpinMessage(chatId: number, messageId?: number): Promise<boolean> {
1041
- try {
1042
- if (messageId) {
1043
- await this.telegram.unpinChatMessage(chatId, messageId);
1044
- } else {
1045
- await this.telegram.unpinAllChatMessages(chatId);
1046
- }
1047
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 取消置顶消息(聊天 ${chatId})`);
1048
- return true;
1049
- } catch (error) {
1050
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 取消置顶失败:`, error);
1051
- throw error;
362
+ if (messageId != null) {
363
+ await this.callApi('unpinChatMessage', { chat_id: chatId, message_id: messageId });
364
+ } else {
365
+ await this.callApi('unpinAllChatMessages', { chat_id: chatId });
1052
366
  }
367
+ return true;
1053
368
  }
1054
369
 
1055
- /**
1056
- * 获取聊天信息
1057
- * @param chatId 聊天 ID
1058
- */
1059
- async getChatInfo(chatId: number): Promise<any> {
1060
- try {
1061
- return await this.telegram.getChat(chatId);
1062
- } catch (error) {
1063
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 获取聊天信息失败:`, error);
1064
- throw error;
1065
- }
1066
- }
1067
-
1068
- /**
1069
- * 获取聊天成员
1070
- * @param chatId 聊天 ID
1071
- * @param userId 用户 ID
1072
- */
1073
- async getChatMember(chatId: number, userId: number): Promise<ChatMember> {
1074
- try {
1075
- return await this.telegram.getChatMember(chatId, userId);
1076
- } catch (error) {
1077
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 获取成员信息失败:`, error);
1078
- throw error;
1079
- }
370
+ async setChatDescription(chatId: number, description: string): Promise<boolean> {
371
+ await this.callApi('setChatDescription', { chat_id: chatId, description });
372
+ return true;
1080
373
  }
1081
374
 
1082
- /**
1083
- * 获取管理员列表
1084
- * @param chatId 聊天 ID
1085
- */
1086
- async getChatAdmins(chatId: number): Promise<ChatMember[]> {
1087
- try {
1088
- return await this.telegram.getChatAdministrators(chatId);
1089
- } catch (error) {
1090
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 获取管理员列表失败:`, error);
1091
- throw error;
1092
- }
375
+ async setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean> {
376
+ await this.callApi('setMessageReaction', {
377
+ chat_id: chatId,
378
+ message_id: messageId,
379
+ reaction: [{ type: 'emoji', emoji: reaction }],
380
+ });
381
+ return true;
1093
382
  }
1094
383
 
1095
- /**
1096
- * 获取聊天成员数量
1097
- * @param chatId 聊天 ID
1098
- */
1099
384
  async getChatMemberCount(chatId: number): Promise<number> {
1100
- try {
1101
- return await this.telegram.getChatMembersCount(chatId);
1102
- } catch (error) {
1103
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 获取成员数量失败:`, error);
1104
- throw error;
1105
- }
385
+ return this.callApi<number>('getChatMemberCount', { chat_id: chatId });
1106
386
  }
1107
387
 
1108
- /**
1109
- * 创建邀请链接
1110
- * @param chatId 聊天 ID
1111
- */
1112
- async createInviteLink(chatId: number): Promise<string> {
1113
- try {
1114
- const link = await this.telegram.createChatInviteLink(chatId, {});
1115
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 创建邀请链接(聊天 ${chatId})`);
1116
- return link.invite_link;
1117
- } catch (error) {
1118
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 创建邀请链接失败:`, error);
1119
- throw error;
1120
- }
388
+ async getChatAdmins(chatId: number): Promise<TelegramChatMember[]> {
389
+ return this.callApi<TelegramChatMember[]>('getChatAdministrators', { chat_id: chatId });
1121
390
  }
1122
391
 
1123
- async sendPoll(chatId: number, question: string, options: string[], isAnonymous: boolean = true, allowsMultipleAnswers: boolean = false): Promise<TelegramMessage> {
1124
- try {
1125
- const result = await this.telegram.sendPoll(chatId, question, options, {
1126
- is_anonymous: isAnonymous,
1127
- allows_multiple_answers: allowsMultipleAnswers,
1128
- } as any);
1129
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 发送投票到 ${chatId}`);
1130
- return result;
1131
- } catch (error) {
1132
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 发送投票失败:`, error);
1133
- throw error;
1134
- }
392
+ async sendStickerMessage(chatId: number, sticker: string): Promise<{ message_id: number }> {
393
+ return this.callApi<{ message_id: number }>('sendSticker', { chat_id: chatId, sticker });
1135
394
  }
1136
395
 
1137
- async setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean> {
1138
- try {
1139
- await (this.telegram as any).callApi('setMessageReaction', {
1140
- chat_id: chatId,
1141
- message_id: messageId,
1142
- reaction: [{ type: 'emoji', emoji: reaction }],
1143
- });
1144
- return true;
1145
- } catch (error) {
1146
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 设置反应失败:`, error);
1147
- throw error;
1148
- }
396
+ async setChatPermissionsAll(
397
+ chatId: number,
398
+ permissions: Record<string, boolean | undefined>,
399
+ ): Promise<boolean> {
400
+ await this.callApi('setChatPermissions', { chat_id: chatId, permissions });
401
+ return true;
1149
402
  }
1150
403
 
1151
- async sendStickerMessage(chatId: number, sticker: string): Promise<TelegramMessage> {
1152
- try {
1153
- const result = await this.telegram.sendSticker(chatId, sticker);
1154
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 发送贴纸到 ${chatId}`);
1155
- return result;
1156
- } catch (error) {
1157
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 发送贴纸失败:`, error);
1158
- throw error;
1159
- }
404
+ async createInviteLink(chatId: number): Promise<string> {
405
+ const link = await this.callApi<{ invite_link: string }>('createChatInviteLink', { chat_id: chatId });
406
+ return link.invite_link;
1160
407
  }
1161
408
 
1162
- async setChatPermissionsAll(chatId: number, permissions: {
1163
- can_send_messages?: boolean;
1164
- can_send_audios?: boolean;
1165
- can_send_documents?: boolean;
1166
- can_send_photos?: boolean;
1167
- can_send_videos?: boolean;
1168
- can_send_video_notes?: boolean;
1169
- can_send_voice_notes?: boolean;
1170
- can_send_polls?: boolean;
1171
- can_send_other_messages?: boolean;
1172
- can_add_web_page_previews?: boolean;
1173
- can_change_info?: boolean;
1174
- can_invite_users?: boolean;
1175
- can_pin_messages?: boolean;
1176
- can_manage_topics?: boolean;
1177
- }): Promise<boolean> {
1178
- try {
1179
- await this.telegram.setChatPermissions(chatId, permissions);
1180
- this.pluginLogger.info(`Telegram Endpoint ${this.$id} 设置聊天 ${chatId} 权限`);
1181
- return true;
1182
- } catch (error) {
1183
- this.pluginLogger.error(`Telegram Endpoint ${this.$id} 设置聊天权限失败:`, error);
1184
- throw error;
1185
- }
409
+ async sendPoll(
410
+ chatId: number,
411
+ question: string,
412
+ options: string[],
413
+ isAnonymous = true,
414
+ allowsMultipleAnswers = false,
415
+ ): Promise<{ message_id: number }> {
416
+ return this.callApi<{ message_id: number }>('sendPoll', {
417
+ chat_id: chatId,
418
+ question,
419
+ options,
420
+ is_anonymous: isAnonymous,
421
+ allows_multiple_answers: allowsMultipleAnswers,
422
+ });
1186
423
  }
1187
424
  }
1188
-