@zhin.js/adapter-telegram 1.0.69 → 1.1.2

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 (93) hide show
  1. package/CHANGELOG.md +909 -17
  2. package/PERMITS.md +24 -0
  3. package/README.md +106 -77
  4. package/adapters/telegram/index.js +34 -0
  5. package/adapters/telegram/index.ts +39 -0
  6. package/agents/telegram/agent.json +20 -0
  7. package/agents/telegram/boundaries.md +3 -0
  8. package/agents/telegram/conventions.md +3 -0
  9. package/agents/telegram/skills/telegram-chat-admin/SKILL.md +24 -0
  10. package/agents/telegram/skills/telegram-chat-admin/tools/create_invite/index.js +18 -0
  11. package/agents/telegram/skills/telegram-chat-admin/tools/create_invite/index.ts +18 -0
  12. package/agents/telegram/skills/telegram-chat-admin/tools/list_admins/index.js +24 -0
  13. package/agents/telegram/skills/telegram-chat-admin/tools/list_admins/index.ts +24 -0
  14. package/agents/telegram/skills/telegram-chat-admin/tools/member_count/index.js +16 -0
  15. package/agents/telegram/skills/telegram-chat-admin/tools/member_count/index.ts +16 -0
  16. package/agents/telegram/skills/telegram-chat-admin/tools/pin_message/index.js +19 -0
  17. package/agents/telegram/skills/telegram-chat-admin/tools/pin_message/index.ts +19 -0
  18. package/agents/telegram/skills/telegram-chat-admin/tools/set_description/index.js +17 -0
  19. package/agents/telegram/skills/telegram-chat-admin/tools/set_description/index.ts +17 -0
  20. package/agents/telegram/skills/telegram-chat-admin/tools/set_permissions/index.js +32 -0
  21. package/agents/telegram/skills/telegram-chat-admin/tools/set_permissions/index.ts +31 -0
  22. package/agents/telegram/skills/telegram-chat-admin/tools/unpin_message/index.js +19 -0
  23. package/agents/telegram/skills/telegram-chat-admin/tools/unpin_message/index.ts +19 -0
  24. package/agents/telegram/skills/telegram-messages/SKILL.md +19 -0
  25. package/agents/telegram/skills/telegram-messages/tools/react/index.js +18 -0
  26. package/agents/telegram/skills/telegram-messages/tools/react/index.ts +18 -0
  27. package/agents/telegram/skills/telegram-messages/tools/send_poll/index.js +30 -0
  28. package/agents/telegram/skills/telegram-messages/tools/send_poll/index.ts +32 -0
  29. package/agents/telegram/skills/telegram-messages/tools/send_sticker/index.js +17 -0
  30. package/agents/telegram/skills/telegram-messages/tools/send_sticker/index.ts +17 -0
  31. package/agents/telegram/system.md +3 -0
  32. package/commands/telegram/endpoint/add/[id]/index.js +3 -0
  33. package/commands/telegram/endpoint/add/[id]/index.ts +3 -0
  34. package/commands/telegram/endpoint/definition.js +17 -0
  35. package/commands/telegram/endpoint/definition.ts +17 -0
  36. package/commands/telegram/endpoint/list/index.js +3 -0
  37. package/commands/telegram/endpoint/list/index.ts +3 -0
  38. package/commands/telegram/endpoint/remove/[id]/index.js +3 -0
  39. package/commands/telegram/endpoint/remove/[id]/index.ts +3 -0
  40. package/lib/client.d.ts +12 -0
  41. package/lib/client.js +2 -0
  42. package/lib/endpoint.d.ts +112 -0
  43. package/lib/endpoint.js +535 -0
  44. package/lib/index.d.ts +4 -18
  45. package/lib/index.js +4 -455
  46. package/lib/markdown-to-html.d.ts +9 -0
  47. package/lib/markdown-to-html.js +75 -0
  48. package/lib/platform-permit.d.ts +17 -0
  49. package/lib/platform-permit.js +51 -0
  50. package/lib/polling.d.ts +9 -0
  51. package/lib/polling.js +57 -0
  52. package/lib/protocol.d.ts +291 -0
  53. package/lib/protocol.js +463 -0
  54. package/lib/telegram-runtime-state.d.ts +1 -0
  55. package/lib/telegram-runtime-state.js +6 -0
  56. package/lib/webhook.d.ts +12 -0
  57. package/lib/webhook.js +55 -0
  58. package/package.json +72 -29
  59. package/plugin.js +19 -0
  60. package/schema.json +141 -0
  61. package/src/client.ts +16 -0
  62. package/src/endpoint.ts +679 -0
  63. package/src/index.ts +39 -426
  64. package/src/markdown-to-html.ts +86 -0
  65. package/src/platform-permit.ts +65 -0
  66. package/src/polling.ts +76 -0
  67. package/src/protocol.ts +744 -0
  68. package/src/telegram-runtime-state.ts +7 -0
  69. package/src/webhook.ts +75 -0
  70. package/client/Dashboard.tsx +0 -295
  71. package/client/index.tsx +0 -11
  72. package/client/tsconfig.json +0 -7
  73. package/client/utils/api.ts +0 -17
  74. package/dist/index.js +0 -32
  75. package/lib/adapter.d.ts +0 -18
  76. package/lib/adapter.d.ts.map +0 -1
  77. package/lib/adapter.js +0 -55
  78. package/lib/adapter.js.map +0 -1
  79. package/lib/bot.d.ts +0 -140
  80. package/lib/bot.d.ts.map +0 -1
  81. package/lib/bot.js +0 -866
  82. package/lib/bot.js.map +0 -1
  83. package/lib/index.d.ts.map +0 -1
  84. package/lib/index.js.map +0 -1
  85. package/lib/types.d.ts +0 -29
  86. package/lib/types.d.ts.map +0 -1
  87. package/lib/types.js +0 -2
  88. package/lib/types.js.map +0 -1
  89. package/plugin.yml +0 -3
  90. package/skills/telegram/SKILL.md +0 -103
  91. package/src/adapter.ts +0 -64
  92. package/src/bot.ts +0 -983
  93. package/src/types.ts +0 -32
@@ -0,0 +1,679 @@
1
+ import {
2
+ Endpoint, createEndpointLifecycle,
3
+ type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
4
+ type EndpointControl, type EndpointSendRequest,
5
+ } from 'zhin.js/adapter';
6
+ /**
7
+ * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
8
+ */
9
+ import { readFile } from 'node:fs/promises';
10
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
11
+ import {
12
+ type ConversationRef,
13
+ type ConversationReference,
14
+ type ConversationResolution,
15
+ type MessageRef,
16
+ } from '@zhin.js/im-contract';
17
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
18
+ import type { CapabilityId } from 'zhin.js';
19
+ import { runTelegramPollLoop } from './polling.js';
20
+ import { normalizeTelegramChatMember } from './platform-permit.js';
21
+ import {
22
+ botApiUrl,
23
+ buildWebhookUrl,
24
+ formatCallbackContent,
25
+ formatCallbackSegments,
26
+ formatInboundContent,
27
+ formatInboundSegments,
28
+ formatOutboundPlan,
29
+ resolveTelegramChannelType,
30
+ senderDisplayName,
31
+ telegramInboundConversation,
32
+ type ResolvedTelegramConfig,
33
+ type TelegramCallbackQuery,
34
+ type TelegramChatMember,
35
+ type TelegramMessage,
36
+ type TelegramOutboundUpload,
37
+ type TelegramUpdate,
38
+ } from './protocol.js';
39
+ import { registerTelegramWebhookRoutes } from './webhook.js';
40
+
41
+ const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
42
+ const CHAT_MEMBER_CACHE_MAX = 2_000;
43
+
44
+ interface ChatMemberPermit {
45
+ readonly at: number;
46
+ readonly role?: string;
47
+ readonly permissions: string[];
48
+ }
49
+
50
+ export type TelegramFetch = (
51
+ url: string,
52
+ init?: {
53
+ readonly method?: string;
54
+ readonly headers?: Record<string, string>;
55
+ readonly body?: string | FormData;
56
+ readonly signal?: AbortSignal;
57
+ },
58
+ ) => Promise<{
59
+ readonly ok: boolean;
60
+ readonly status: number;
61
+ text(): Promise<string>;
62
+ json(): Promise<unknown>;
63
+ arrayBuffer?(): Promise<ArrayBuffer>;
64
+ readonly headers?: { get(name: string): string | null };
65
+ }>;
66
+
67
+ export interface TelegramEndpointOptions {
68
+ readonly id: CapabilityId;
69
+ readonly config: ResolvedTelegramConfig;
70
+ readonly http?: HttpHost;
71
+ readonly fetch?: TelegramFetch;
72
+ }
73
+
74
+ export interface TelegramClientApi {
75
+ callApi<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
76
+ callApiForm<T = unknown>(method: string, form: FormData): Promise<T>;
77
+ pinMessage(chatId: number, messageId: number): Promise<boolean>;
78
+ unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
79
+ setChatDescription(chatId: number, description: string): Promise<boolean>;
80
+ setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean>;
81
+ getChatMemberCount(chatId: number): Promise<number>;
82
+ getChatAdmins(chatId: number): Promise<TelegramChatMember[]>;
83
+ sendStickerMessage(chatId: number, sticker: string): Promise<{ message_id: number }>;
84
+ setChatPermissionsAll(chatId: number, permissions: Record<string, boolean | undefined>): Promise<boolean>;
85
+ createInviteLink(chatId: number): Promise<string>;
86
+ sendPoll(
87
+ chatId: number,
88
+ question: string,
89
+ options: string[],
90
+ isAnonymous?: boolean,
91
+ allowsMultipleAnswers?: boolean,
92
+ ): Promise<{ message_id: number }>;
93
+ }
94
+
95
+ /** Telegram Bot API client carried by message, update and lifecycle events. */
96
+ export class TelegramClient implements TelegramClientApi {
97
+ constructor(readonly api: TelegramClientApi) {}
98
+ callApi = <T = unknown>(method: string, params: Record<string, unknown> = {}) =>
99
+ this.api.callApi<T>(method, params);
100
+ callApiForm = <T = unknown>(method: string, form: FormData) =>
101
+ this.api.callApiForm<T>(method, form);
102
+ pinMessage = (chatId: number, messageId: number) => this.api.pinMessage(chatId, messageId);
103
+ unpinMessage = (chatId: number, messageId?: number) => this.api.unpinMessage(chatId, messageId);
104
+ setChatDescription = (chatId: number, description: string) =>
105
+ this.api.setChatDescription(chatId, description);
106
+ setMessageReaction = (chatId: number, messageId: number, reaction: string) =>
107
+ this.api.setMessageReaction(chatId, messageId, reaction);
108
+ getChatMemberCount = (chatId: number) => this.api.getChatMemberCount(chatId);
109
+ getChatAdmins = (chatId: number) => this.api.getChatAdmins(chatId);
110
+ sendStickerMessage = (chatId: number, sticker: string) =>
111
+ this.api.sendStickerMessage(chatId, sticker);
112
+ setChatPermissionsAll = (chatId: number, permissions: Record<string, boolean | undefined>) =>
113
+ this.api.setChatPermissionsAll(chatId, permissions);
114
+ createInviteLink = (chatId: number) => this.api.createInviteLink(chatId);
115
+ sendPoll = (
116
+ chatId: number,
117
+ question: string,
118
+ options: string[],
119
+ anonymous?: boolean,
120
+ multiple?: boolean,
121
+ ) => this.api.sendPoll(chatId, question, options, anonymous, multiple);
122
+ }
123
+
124
+ interface TelegramApiOk<T> {
125
+ readonly ok: true;
126
+ readonly result: T;
127
+ }
128
+
129
+ interface TelegramApiErr {
130
+ readonly ok: false;
131
+ readonly description?: string;
132
+ readonly error_code?: number;
133
+ }
134
+
135
+ /**
136
+ * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
137
+ * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
138
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
139
+ */
140
+ export class TelegramEndpoint extends Endpoint<TelegramClient> {
141
+ readonly client = new TelegramClient({
142
+ callApi: (method, params) => this.callApi(method, params),
143
+ callApiForm: (method, form) => this.callApiForm(method, form),
144
+ pinMessage: (chatId, messageId) => this.pinMessage(chatId, messageId),
145
+ unpinMessage: (chatId, messageId) => this.unpinMessage(chatId, messageId),
146
+ setChatDescription: (chatId, description) => this.setChatDescription(chatId, description),
147
+ setMessageReaction: (chatId, messageId, reaction) =>
148
+ this.setMessageReaction(chatId, messageId, reaction),
149
+ getChatMemberCount: (chatId) => this.getChatMemberCount(chatId),
150
+ getChatAdmins: (chatId) => this.getChatAdmins(chatId),
151
+ sendStickerMessage: (chatId, sticker) => this.sendStickerMessage(chatId, sticker),
152
+ setChatPermissionsAll: (chatId, permissions) =>
153
+ this.setChatPermissionsAll(chatId, permissions),
154
+ createInviteLink: (chatId) => this.createInviteLink(chatId),
155
+ sendPoll: (chatId, question, options, anonymous, multiple) =>
156
+ this.sendPoll(chatId, question, options, anonymous, multiple),
157
+ });
158
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
159
+
160
+ readonly #options: TelegramEndpointOptions;
161
+ readonly #fetch: TelegramFetch;
162
+ #pollAbort?: AbortController;
163
+ #pollPromise?: Promise<void>;
164
+ readonly #lifecycle: EndpointLifecycle;
165
+ #open = false;
166
+ #admission?: AbortController;
167
+ #updateOffset = 0;
168
+ #botUserId?: number;
169
+ #botUsername?: string;
170
+ readonly #chatMemberCache = new Map<string, ChatMemberPermit>();
171
+ readonly control: EndpointControl = Object.freeze({
172
+ recall: (message: MessageRef) => this.recallMessage(message),
173
+ });
174
+ readonly content: EndpointContentPort = Object.freeze({
175
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) => this.#resolveContent(reference, context.signal),
176
+ });
177
+
178
+ constructor(options: TelegramEndpointOptions) {
179
+ super();
180
+ this.#logger = getAdapterLogger('telegram', options.config.id);
181
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
182
+ this.#options = options;
183
+ this.#fetch = options.fetch ?? globalThis.fetch;
184
+ }
185
+
186
+ /** Used by webhook handler. */
187
+ get isOpen(): boolean {
188
+ return this.#open;
189
+ }
190
+
191
+ get config(): ResolvedTelegramConfig {
192
+ return this.#options.config;
193
+ }
194
+
195
+ get allowedUpdates(): readonly string[] {
196
+ return this.#options.config.allowedUpdates;
197
+ }
198
+
199
+ getUpdateOffset(): number {
200
+ return this.#updateOffset;
201
+ }
202
+
203
+ setUpdateOffset(offset: number): void {
204
+ this.#updateOffset = offset;
205
+ }
206
+
207
+ async start(): Promise<void> {
208
+ await this.#lifecycle.start(async (handle) => {
209
+ const abort = new AbortController();
210
+ const routes: HttpRouteRegistration[] = [];
211
+ this.#pollAbort = abort;
212
+ const cleanup = () => {
213
+ abort.abort();
214
+ for (const release of routes.splice(0)) release();
215
+ if (this.#pollAbort === abort) {
216
+ this.#botUserId = undefined;
217
+ this.#botUsername = undefined;
218
+ }
219
+ };
220
+ handle.onForceClose(cleanup);
221
+ try {
222
+ const me = await this.callApi<{ id?: number; username?: string; first_name?: string }>('getMe', {}, abort.signal);
223
+ if (abort.signal.aborted) return;
224
+ this.#botUserId = me.id;
225
+ this.#botUsername = me.username;
226
+
227
+ if (this.#options.config.mode === 'webhook') {
228
+ if (!this.#options.http) {
229
+ throw new TypeError('Telegram webhook mode requires httpHostToken');
230
+ }
231
+ routes.push(...registerTelegramWebhookRoutes(this.#options.http, this));
232
+ const webhook = this.#options.config.webhook!;
233
+ if (!webhook.secretToken) {
234
+ // 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
235
+ this.#logger.warn(formatCompact({
236
+ op: 'webhook_no_secret',
237
+ endpoint: this.#options.config.id,
238
+ path: webhook.path,
239
+ hint: 'set webhook.secretToken to authenticate Telegram callbacks',
240
+ }));
241
+ }
242
+ const url = buildWebhookUrl(webhook);
243
+ await this.callApi('setWebhook', {
244
+ url,
245
+ allowed_updates: this.#options.config.allowedUpdates,
246
+ ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
247
+ }, abort.signal);
248
+ if (abort.signal.aborted) return;
249
+ this.#logger.info(formatCompact({
250
+ op: 'connect',
251
+ endpoint: this.#options.config.id,
252
+ mode: 'webhook',
253
+ path: webhook.path,
254
+ username: me.username,
255
+ }));
256
+ return;
257
+ }
258
+
259
+ await this.callApi('deleteWebhook', { drop_pending_updates: false }, abort.signal);
260
+ if (abort.signal.aborted) return;
261
+ this.#pollPromise = runTelegramPollLoop(this, abort.signal);
262
+ this.#logger.info(formatCompact({
263
+ op: 'connect',
264
+ endpoint: this.#options.config.id,
265
+ mode: 'polling',
266
+ username: me.username,
267
+ }));
268
+ } catch (error) {
269
+ const cancelled = abort.signal.aborted;
270
+ cleanup();
271
+ if (cancelled) return;
272
+ this.#logger.error('Failed to connect Telegram bot:', error);
273
+ throw error;
274
+ }
275
+ });
276
+ }
277
+
278
+ open(): void {
279
+ if (this.#open) return;
280
+ this.#admission = new AbortController();
281
+ this.#open = true;
282
+ }
283
+
284
+ close(): void {
285
+ this.#open = false;
286
+ this.#admission?.abort();
287
+ this.#chatMemberCache.clear();
288
+ }
289
+
290
+ async stop(): Promise<void> {
291
+ this.close();
292
+ const polling = this.#pollPromise;
293
+ this.#pollPromise = undefined;
294
+ await this.#lifecycle.stop();
295
+ try {
296
+ await polling;
297
+ } catch {
298
+ /* poll loop exit */
299
+ }
300
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
301
+ }
302
+
303
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
304
+ const plan = formatOutboundPlan(conversation.id, payload);
305
+ let lastId = '';
306
+ for (const action of plan.actions) {
307
+ const form = await this.#buildUploadForm(action.params, plan.uploads);
308
+ const result = form
309
+ ? await this.callApiForm<{ message_id?: number }>(action.method, form)
310
+ : await this.callApi<{ message_id?: number }>(action.method, action.params);
311
+ if (!result || !Number.isSafeInteger(result.message_id) || result.message_id! <= 0) {
312
+ throw new Error(`Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`);
313
+ }
314
+ lastId = String(result.message_id);
315
+ }
316
+ if (!lastId) throw new Error('Telegram message contains no sendable actions');
317
+ return lastId;
318
+ }
319
+
320
+ async recallMessage(message: MessageRef): Promise<void> {
321
+ if (!message.id || message.id.startsWith('telegram-')) return;
322
+ await this.callApi('deleteMessage', {
323
+ chat_id: message.conversation.id,
324
+ message_id: Number(message.id),
325
+ });
326
+ }
327
+
328
+ async #resolveContent(
329
+ reference: Parameters<EndpointContentPort['resolve']>[0],
330
+ signal: AbortSignal,
331
+ ): Promise<ConversationResolution> {
332
+ if (reference.kind !== 'media') {
333
+ return Object.freeze({ status: 'unsupported', code: 'telegram_message_lookup_unavailable' });
334
+ }
335
+ if (reference.media.kind !== 'file') {
336
+ return Object.freeze({ status: 'resolved', reference, value: reference.media });
337
+ }
338
+ try {
339
+ signal.throwIfAborted();
340
+ const file = await this.callApi<{ file_path?: string; file_size?: number }>('getFile', {
341
+ file_id: reference.media.value,
342
+ });
343
+ if (!file.file_path) return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
344
+ if ((file.file_size ?? 0) > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
345
+ const response = await this.#fetch(
346
+ `${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`,
347
+ { signal },
348
+ );
349
+ if (!response.ok) return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
350
+ if (!response.arrayBuffer) return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
351
+ const bytes = Buffer.from(await response.arrayBuffer());
352
+ if (bytes.byteLength > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
353
+ return Object.freeze({
354
+ status: 'resolved',
355
+ reference,
356
+ value: Object.freeze({
357
+ kind: 'base64',
358
+ value: bytes.toString('base64'),
359
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
360
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
361
+ : {}),
362
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
363
+ size: bytes.byteLength,
364
+ }),
365
+ });
366
+ } catch (error) {
367
+ if (signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
368
+ return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: error instanceof Error ? error.message : String(error) });
369
+ }
370
+ }
371
+
372
+ /**
373
+ * 含 `attach://` 占位的媒体参数 → multipart/form-data:
374
+ * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
375
+ * (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
376
+ */
377
+ async #buildUploadForm(
378
+ params: Record<string, unknown>,
379
+ uploads: readonly TelegramOutboundUpload[],
380
+ ): Promise<FormData | undefined> {
381
+ const values = Object.values(params);
382
+ if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://'))) return undefined;
383
+ const form = new FormData();
384
+ for (const [key, value] of Object.entries(params)) {
385
+ if (value == null) continue;
386
+ if (typeof value === 'string' && value.startsWith('attach://')) {
387
+ const upload = uploads.find((item) => `attach://${item.attachName}` === value);
388
+ if (!upload) throw new Error(`Telegram upload 未登记: ${value}`);
389
+ const data = upload.source.kind === 'base64'
390
+ ? Buffer.from(upload.source.data, 'base64')
391
+ : await readFile(upload.source.path);
392
+ form.append(
393
+ key,
394
+ new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined),
395
+ upload.filename,
396
+ );
397
+ continue;
398
+ }
399
+ form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
400
+ }
401
+ return form;
402
+ }
403
+
404
+ /** Test / internal: admit a message when open. */
405
+ admit(msg: TelegramMessage): void {
406
+ if (!this.#open || !this.#admission) return;
407
+ const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
408
+ void this.#admitWithSenderRole(msg, conversation, this.#admission.signal).catch((err) => {
409
+ this.#logger.warn(formatCompact({
410
+ op: 'telegram_gateway_receive_failed',
411
+ target: `${conversation.kind}:${conversation.id}`,
412
+ error: err instanceof Error ? err.message : String(err),
413
+ }));
414
+ });
415
+ }
416
+
417
+ async #admitWithSenderRole(msg: TelegramMessage, conversation: ConversationRef, signal: AbortSignal): Promise<void> {
418
+ const permit = await this.#resolveGroupSenderPermit(msg, signal);
419
+ if (signal.aborted) return;
420
+ // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
421
+ const mentioned = this.#isBotMentioned(msg);
422
+ await this.emit('message.receive', {
423
+ conversation,
424
+ message: { conversation, id: String(msg.message_id) },
425
+ content: formatInboundContent(msg),
426
+ segments: formatInboundSegments(msg),
427
+ sender: {
428
+ id: String(msg.from?.id ?? ''),
429
+ name: senderDisplayName(msg.from) || undefined,
430
+ ...(permit?.role ? { roles: [permit.role] } : {}),
431
+ },
432
+ endpointId: this.#options.config.id,
433
+ ...(mentioned ? { mentioned: true } : {}),
434
+ metadata: Object.freeze({
435
+ channelType: resolveTelegramChannelType(msg.chat.type),
436
+ chatType: msg.chat.type,
437
+ userId: msg.from?.id,
438
+ date: msg.date,
439
+ ...(permit?.role ? { senderRole: permit.role } : {}),
440
+ ...(permit?.permissions.length ? { senderPermissions: [...permit.permissions] } : {}),
441
+ }),
442
+ });
443
+ }
444
+
445
+ /** entities 里 mention 文本命中 bot username(getMe 缓存),或 text_mention 指向 bot 用户。 */
446
+ #isBotMentioned(msg: TelegramMessage): boolean {
447
+ if (!msg.entities?.length) return false;
448
+ for (const entity of msg.entities) {
449
+ if (entity.type === 'text_mention') {
450
+ if (this.#botUserId != null && entity.user?.id === this.#botUserId) return true;
451
+ continue;
452
+ }
453
+ if (entity.type !== 'mention' || !this.#botUsername) continue;
454
+ const slice = (msg.text ?? '').slice(entity.offset, entity.offset + entity.length);
455
+ if (slice.toLowerCase() === `@${this.#botUsername.toLowerCase()}`) return true;
456
+ }
457
+ return false;
458
+ }
459
+
460
+ /** 群消息 sender role 解析:getChatMember + 60s 缓存(对齐旧 enrichGroupSender)。 */
461
+ async #resolveGroupSenderPermit(msg: TelegramMessage, signal: AbortSignal): Promise<ChatMemberPermit | undefined> {
462
+ if (msg.chat.type === 'private' || !msg.from?.id) return undefined;
463
+ const chatId = Number(msg.chat.id);
464
+ const userId = msg.from.id;
465
+ const key = `${chatId}:${userId}`;
466
+ const now = Date.now();
467
+ this.#sweepChatMemberCache(now);
468
+ const cached = this.#chatMemberCache.get(key);
469
+ if (cached && now - cached.at < CHAT_MEMBER_CACHE_TTL_MS) return cached;
470
+ try {
471
+ const member = await this.callApi<TelegramChatMember>('getChatMember', {
472
+ chat_id: chatId,
473
+ user_id: userId,
474
+ }, signal);
475
+ if (signal.aborted) return undefined;
476
+ const normalized = normalizeTelegramChatMember(member);
477
+ const entry: ChatMemberPermit = { at: now, ...normalized };
478
+ this.#chatMemberCache.set(key, entry);
479
+ return entry;
480
+ } catch {
481
+ // 保守拒绝:无角色快照
482
+ return undefined;
483
+ }
484
+ }
485
+
486
+ #sweepChatMemberCache(now: number): void {
487
+ for (const [key, entry] of this.#chatMemberCache) {
488
+ if (now - entry.at >= CHAT_MEMBER_CACHE_TTL_MS) this.#chatMemberCache.delete(key);
489
+ }
490
+ if (this.#chatMemberCache.size > CHAT_MEMBER_CACHE_MAX) {
491
+ const excess = this.#chatMemberCache.size - CHAT_MEMBER_CACHE_MAX;
492
+ let removed = 0;
493
+ for (const [key] of this.#chatMemberCache) {
494
+ if (removed >= excess) break;
495
+ this.#chatMemberCache.delete(key);
496
+ removed++;
497
+ }
498
+ }
499
+ }
500
+
501
+ /** Test / internal: admit a callback query when open. */
502
+ admitCallback(query: TelegramCallbackQuery): void {
503
+ if (!this.#open) return;
504
+ const endpointKey = String(this.#options.id);
505
+ const msg = query.message;
506
+ // 无挂载消息的 callback(如 inline 模式)退化为 sender 私聊会话
507
+ const conversation = msg
508
+ ? telegramInboundConversation(endpointKey, msg.chat)
509
+ : telegramInboundConversation(endpointKey, { id: query.from.id, type: 'private' });
510
+ void this.emit('message.receive', {
511
+ conversation,
512
+ message: { conversation, id: query.id },
513
+ content: formatCallbackContent(query),
514
+ segments: formatCallbackSegments(query),
515
+ sender: { id: String(query.from?.id ?? ''), name: senderDisplayName(query.from) || undefined },
516
+ endpointId: this.#options.config.id,
517
+ metadata: Object.freeze({
518
+ eventType: 'callback_query',
519
+ payload: query.data,
520
+ sourceMessageId: msg ? String(msg.message_id) : undefined,
521
+ }),
522
+ }).catch((err) => {
523
+ this.#logger.warn(formatCompact({
524
+ op: 'telegram_gateway_receive_failed',
525
+ target: `${conversation.kind}:${conversation.id}`,
526
+ error: err instanceof Error ? err.message : String(err),
527
+ }));
528
+ });
529
+ }
530
+
531
+ /** Used by webhook / polling handlers. */
532
+ handleUpdate(update: TelegramUpdate): void {
533
+ const eventName = update.message
534
+ ? 'message'
535
+ : update.callback_query
536
+ ? 'callback_query'
537
+ : 'update';
538
+ void this.emitPlatform(eventName, update).catch((error) => {
539
+ this.#logger.warn(formatCompact({
540
+ op: 'telegram_platform_event_failed',
541
+ event: eventName,
542
+ error: error instanceof Error ? error.message : String(error),
543
+ }));
544
+ });
545
+ if (update.message) {
546
+ this.admit(update.message);
547
+ return;
548
+ }
549
+ if (update.callback_query) {
550
+ const query = update.callback_query;
551
+ if (query.data) {
552
+ void this.callApi('answerCallbackQuery', { callback_query_id: query.id }).catch(() => {
553
+ /* already answered */
554
+ });
555
+ }
556
+ this.admitCallback(query);
557
+ }
558
+ }
559
+
560
+ async callApi<T = unknown>(
561
+ method: string,
562
+ params: Record<string, unknown> = {},
563
+ signal?: AbortSignal,
564
+ ): Promise<T> {
565
+ const url = botApiUrl(this.#options.config, method);
566
+ const response = await this.#fetch(url, {
567
+ method: 'POST',
568
+ headers: { 'Content-Type': 'application/json' },
569
+ body: JSON.stringify(params),
570
+ signal,
571
+ });
572
+ return this.#parseApiResponse<T>(method, response);
573
+ }
574
+
575
+ /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
576
+ async callApiForm<T = unknown>(
577
+ method: string,
578
+ form: FormData,
579
+ signal?: AbortSignal,
580
+ ): Promise<T> {
581
+ const url = botApiUrl(this.#options.config, method);
582
+ const response = await this.#fetch(url, {
583
+ method: 'POST',
584
+ body: form,
585
+ signal,
586
+ });
587
+ return this.#parseApiResponse<T>(method, response);
588
+ }
589
+
590
+ async #parseApiResponse<T>(
591
+ method: string,
592
+ response: { readonly status: number; text(): Promise<string> },
593
+ ): Promise<T> {
594
+ const text = await response.text();
595
+ let body: TelegramApiOk<T> | TelegramApiErr;
596
+ try {
597
+ body = JSON.parse(text) as TelegramApiOk<T> | TelegramApiErr;
598
+ } catch {
599
+ throw new Error(`Telegram API ${method} invalid JSON (${response.status}): ${text.slice(0, 200)}`);
600
+ }
601
+ if (!body.ok) {
602
+ throw new Error(
603
+ `Telegram API ${method} failed (${body.error_code ?? response.status}): ${body.description ?? text}`,
604
+ );
605
+ }
606
+ return body.result;
607
+ }
608
+
609
+ // ── Agent tool surface ──────────────────────────────────────────────
610
+
611
+ async pinMessage(chatId: number, messageId: number): Promise<boolean> {
612
+ await this.callApi('pinChatMessage', { chat_id: chatId, message_id: messageId });
613
+ return true;
614
+ }
615
+
616
+ async unpinMessage(chatId: number, messageId?: number): Promise<boolean> {
617
+ if (messageId != null) {
618
+ await this.callApi('unpinChatMessage', { chat_id: chatId, message_id: messageId });
619
+ } else {
620
+ await this.callApi('unpinAllChatMessages', { chat_id: chatId });
621
+ }
622
+ return true;
623
+ }
624
+
625
+ async setChatDescription(chatId: number, description: string): Promise<boolean> {
626
+ await this.callApi('setChatDescription', { chat_id: chatId, description });
627
+ return true;
628
+ }
629
+
630
+ async setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean> {
631
+ await this.callApi('setMessageReaction', {
632
+ chat_id: chatId,
633
+ message_id: messageId,
634
+ reaction: [{ type: 'emoji', emoji: reaction }],
635
+ });
636
+ return true;
637
+ }
638
+
639
+ async getChatMemberCount(chatId: number): Promise<number> {
640
+ return this.callApi<number>('getChatMemberCount', { chat_id: chatId });
641
+ }
642
+
643
+ async getChatAdmins(chatId: number): Promise<TelegramChatMember[]> {
644
+ return this.callApi<TelegramChatMember[]>('getChatAdministrators', { chat_id: chatId });
645
+ }
646
+
647
+ async sendStickerMessage(chatId: number, sticker: string): Promise<{ message_id: number }> {
648
+ return this.callApi<{ message_id: number }>('sendSticker', { chat_id: chatId, sticker });
649
+ }
650
+
651
+ async setChatPermissionsAll(
652
+ chatId: number,
653
+ permissions: Record<string, boolean | undefined>,
654
+ ): Promise<boolean> {
655
+ await this.callApi('setChatPermissions', { chat_id: chatId, permissions });
656
+ return true;
657
+ }
658
+
659
+ async createInviteLink(chatId: number): Promise<string> {
660
+ const link = await this.callApi<{ invite_link: string }>('createChatInviteLink', { chat_id: chatId });
661
+ return link.invite_link;
662
+ }
663
+
664
+ async sendPoll(
665
+ chatId: number,
666
+ question: string,
667
+ options: string[],
668
+ isAnonymous = true,
669
+ allowsMultipleAnswers = false,
670
+ ): Promise<{ message_id: number }> {
671
+ return this.callApi<{ message_id: number }>('sendPoll', {
672
+ chat_id: chatId,
673
+ question,
674
+ options,
675
+ is_anonymous: isAnonymous,
676
+ allows_multiple_answers: allowsMultipleAnswers,
677
+ });
678
+ }
679
+ }