@zhin.js/adapter-telegram 6.0.0 → 6.0.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.
package/lib/webhook.d.ts CHANGED
@@ -1,9 +1,8 @@
1
- /**
2
- * Telegram webhook HTTP: secret token → parse → handle update.
3
- */
4
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
2
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
3
  import { type ResolvedTelegramConfig, type TelegramUpdate } from './protocol.js';
4
+ /** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
5
+ export declare function safeTokenEqual(a: string, b: string): boolean;
7
6
  export interface TelegramWebhookHandler {
8
7
  readonly config: ResolvedTelegramConfig;
9
8
  readonly isOpen: boolean;
package/lib/webhook.js CHANGED
@@ -1,6 +1,16 @@
1
+ /**
2
+ * Telegram webhook HTTP: secret token → parse → handle update.
3
+ */
4
+ import { timingSafeEqual } from 'node:crypto';
1
5
  import { getLogger } from '@zhin.js/logger';
2
6
  import { readTextBody } from './protocol.js';
3
7
  const logger = getLogger('telegram');
8
+ /** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
9
+ export function safeTokenEqual(a, b) {
10
+ const bufA = Buffer.from(a, 'utf8');
11
+ const bufB = Buffer.from(b, 'utf8');
12
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
13
+ }
4
14
  export function registerTelegramWebhookRoutes(http, handler) {
5
15
  const path = handler.config.webhook.path;
6
16
  return [
@@ -15,7 +25,7 @@ export async function handleTelegramWebhookRequest(request, response, handler) {
15
25
  if (secret) {
16
26
  const header = request.headers['x-telegram-bot-api-secret-token'];
17
27
  const token = Array.isArray(header) ? header[0] : header;
18
- if (token !== secret) {
28
+ if (!token || !safeTokenEqual(token, secret)) {
19
29
  response.writeHead(403, { 'Content-Type': 'application/json' });
20
30
  response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
21
31
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-telegram",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "description": "Zhin.js Telegram Bot API adapter for Plugin Runtime (long-poll getUpdates)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -32,19 +32,20 @@
32
32
  "directory": "plugins/adapters/telegram"
33
33
  },
34
34
  "dependencies": {
35
- "@zhin.js/adapter": "1.1.0",
36
- "@zhin.js/core": "1.4.0",
37
- "@zhin.js/host-http": "1.0.2",
35
+ "@zhin.js/adapter": "1.1.2",
36
+ "@zhin.js/command": "1.0.4",
37
+ "@zhin.js/core": "1.4.2",
38
+ "@zhin.js/host-http": "1.0.3",
38
39
  "@zhin.js/logger": "1.0.75",
39
- "@zhin.js/plugin-runtime": "1.1.0"
40
+ "@zhin.js/plugin-runtime": "1.1.1"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "zod": "^4.0.0",
43
- "@zhin.js/adapter": "1.1.0",
44
- "@zhin.js/agent": "1.0.5",
45
- "@zhin.js/core": "1.4.0",
46
- "@zhin.js/plugin-runtime": "1.1.0",
47
- "zhin.js": "5.0.0"
44
+ "@zhin.js/adapter": "1.1.2",
45
+ "@zhin.js/agent": "1.0.7",
46
+ "@zhin.js/core": "1.4.2",
47
+ "@zhin.js/plugin-runtime": "1.1.1",
48
+ "zhin.js": "5.0.2"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
51
  "zhin.js": {
@@ -62,13 +63,14 @@
62
63
  "typescript": "^6.0.3",
63
64
  "vitest": "^4.1.10",
64
65
  "zod": "^4.4.3",
65
- "@zhin.js/agent": "1.0.5",
66
- "@zhin.js/host-http": "1.0.2",
67
- "zhin.js": "5.0.0"
66
+ "@zhin.js/agent": "1.0.7",
67
+ "@zhin.js/host-http": "1.0.3",
68
+ "zhin.js": "5.0.2"
68
69
  },
69
70
  "files": [
70
71
  "adapters",
71
- "plugin.ts",
72
+ "commands",
73
+ "plugin.js",
72
74
  "schema.json",
73
75
  "src",
74
76
  "lib",
@@ -86,13 +88,17 @@
86
88
  "zhin": {
87
89
  "protocol": 1,
88
90
  "type": "plugin",
89
- "entry": "./plugin.ts",
91
+ "entry": "./plugin.js",
90
92
  "engine": "^1.0.0",
91
93
  "runtime": "trusted",
92
94
  "features": [
93
95
  {
94
96
  "package": "@zhin.js/adapter",
95
97
  "api": "^1.0.0"
98
+ },
99
+ {
100
+ "package": "@zhin.js/command",
101
+ "api": "^1.0.0"
96
102
  }
97
103
  ],
98
104
  "plugins": []
package/plugin.js ADDED
@@ -0,0 +1,17 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
3
+ import { definePlugin } from '@zhin.js/plugin-runtime';
4
+ import { registerTelegramPlatformPermitChecker } from "./lib/platform-permit.js";
5
+ import { telegramRuntimeStateToken } from "./lib/telegram-runtime-state.js";
6
+ export default definePlugin({
7
+ name: 'telegram',
8
+ metadata: {
9
+ displayName: 'Telegram Bot API Adapter',
10
+ },
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(telegram endpoint list 的"运行中"数据源)
13
+ context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState());
14
+ // 平台权限门禁:chat_creator / chat_administrator / pin_messages 等(agent 工具 platformPermit)
15
+ return registerTelegramPlatformPermitChecker();
16
+ },
17
+ });
package/src/endpoint.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
3
3
  */
4
+ import { readFile } from 'node:fs/promises';
4
5
  import type { EndpointInstance } from '@zhin.js/adapter';
5
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
@@ -12,14 +13,17 @@ import {
12
13
  botApiUrl,
13
14
  buildWebhookUrl,
14
15
  formatCallbackContent,
16
+ formatCallbackSegments,
15
17
  formatInboundContent,
16
- formatOutboundActions,
18
+ formatInboundSegments,
19
+ formatOutboundPlan,
17
20
  resolveChannel,
18
21
  senderDisplayName,
19
22
  type ResolvedTelegramConfig,
20
23
  type TelegramCallbackQuery,
21
24
  type TelegramChatMember,
22
25
  type TelegramMessage,
26
+ type TelegramOutboundUpload,
23
27
  type TelegramUpdate,
24
28
  } from './protocol.js';
25
29
  import { registerTelegramAgentEndpoint } from './telegram-agent-deps.js';
@@ -41,7 +45,7 @@ export type TelegramFetch = (
41
45
  init?: {
42
46
  readonly method?: string;
43
47
  readonly headers?: Record<string, string>;
44
- readonly body?: string;
48
+ readonly body?: string | FormData;
45
49
  readonly signal?: AbortSignal;
46
50
  },
47
51
  ) => Promise<{
@@ -70,6 +74,11 @@ interface TelegramApiErr {
70
74
  readonly error_code?: number;
71
75
  }
72
76
 
77
+ /**
78
+ * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
79
+ * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
80
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
81
+ */
73
82
  export class TelegramEndpoint implements EndpointInstance {
74
83
  readonly #options: TelegramEndpointOptions;
75
84
  readonly #fetch: TelegramFetch;
@@ -125,6 +134,15 @@ export class TelegramEndpoint implements EndpointInstance {
125
134
  }
126
135
  this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
127
136
  const webhook = this.#options.config.webhook!;
137
+ if (!webhook.secretToken) {
138
+ // 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
139
+ logger.warn(formatCompact({
140
+ op: 'webhook_no_secret',
141
+ endpoint: this.#options.config.name,
142
+ path: webhook.path,
143
+ hint: 'set webhook.secretToken to authenticate Telegram callbacks',
144
+ }));
145
+ }
128
146
  const url = buildWebhookUrl(webhook);
129
147
  await this.callApi('setWebhook', {
130
148
  url,
@@ -182,15 +200,50 @@ export class TelegramEndpoint implements EndpointInstance {
182
200
  }
183
201
 
184
202
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
185
- const actions = formatOutboundActions(target, payload);
203
+ const plan = formatOutboundPlan(target, payload);
186
204
  let lastId = '';
187
- for (const action of actions) {
188
- const result = await this.callApi<{ message_id?: number }>(action.method, action.params);
205
+ for (const action of plan.actions) {
206
+ const form = await this.#buildUploadForm(action.params, plan.uploads);
207
+ const result = form
208
+ ? await this.callApiForm<{ message_id?: number }>(action.method, form)
209
+ : await this.callApi<{ message_id?: number }>(action.method, action.params);
189
210
  if (result.message_id != null) lastId = String(result.message_id);
190
211
  }
191
212
  return lastId || `telegram-${Date.now()}`;
192
213
  }
193
214
 
215
+ /**
216
+ * 含 `attach://` 占位的媒体参数 → multipart/form-data:
217
+ * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
218
+ * (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
219
+ */
220
+ async #buildUploadForm(
221
+ params: Record<string, unknown>,
222
+ uploads: readonly TelegramOutboundUpload[],
223
+ ): Promise<FormData | undefined> {
224
+ const values = Object.values(params);
225
+ if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://'))) return undefined;
226
+ const form = new FormData();
227
+ for (const [key, value] of Object.entries(params)) {
228
+ if (value == null) continue;
229
+ if (typeof value === 'string' && value.startsWith('attach://')) {
230
+ const upload = uploads.find((item) => `attach://${item.attachName}` === value);
231
+ if (!upload) throw new Error(`Telegram upload 未登记: ${value}`);
232
+ const data = upload.source.kind === 'base64'
233
+ ? Buffer.from(upload.source.data, 'base64')
234
+ : await readFile(upload.source.path);
235
+ form.append(
236
+ key,
237
+ new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined),
238
+ upload.filename,
239
+ );
240
+ continue;
241
+ }
242
+ form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
243
+ }
244
+ return form;
245
+ }
246
+
194
247
  /** Test / internal: admit a message when open. */
195
248
  admit(msg: TelegramMessage): void {
196
249
  if (!this.#open) return;
@@ -212,6 +265,7 @@ export class TelegramEndpoint implements EndpointInstance {
212
265
  adapter: this.#options.id,
213
266
  target: channelId,
214
267
  content: formatInboundContent(msg),
268
+ segments: formatInboundSegments(msg),
215
269
  sender: senderDisplayName(msg.from),
216
270
  id: String(msg.message_id),
217
271
  metadata: Object.freeze({
@@ -290,6 +344,7 @@ export class TelegramEndpoint implements EndpointInstance {
290
344
  adapter: this.#options.id,
291
345
  target: channelId,
292
346
  content: formatCallbackContent(query),
347
+ segments: formatCallbackSegments(query),
293
348
  sender: senderDisplayName(query.from),
294
349
  id: query.id,
295
350
  metadata: Object.freeze({
@@ -336,6 +391,28 @@ export class TelegramEndpoint implements EndpointInstance {
336
391
  body: JSON.stringify(params),
337
392
  signal,
338
393
  });
394
+ return this.#parseApiResponse<T>(method, response);
395
+ }
396
+
397
+ /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
398
+ async callApiForm<T = unknown>(
399
+ method: string,
400
+ form: FormData,
401
+ signal?: AbortSignal,
402
+ ): Promise<T> {
403
+ const url = botApiUrl(this.#options.config, method);
404
+ const response = await this.#fetch(url, {
405
+ method: 'POST',
406
+ body: form,
407
+ signal,
408
+ });
409
+ return this.#parseApiResponse<T>(method, response);
410
+ }
411
+
412
+ async #parseApiResponse<T>(
413
+ method: string,
414
+ response: { readonly status: number; text(): Promise<string> },
415
+ ): Promise<T> {
339
416
  const text = await response.text();
340
417
  let body: TelegramApiOk<T> | TelegramApiErr;
341
418
  try {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  formatCallbackContent,
10
10
  formatInboundContent,
11
11
  formatOutboundActions,
12
+ formatOutboundPlan,
12
13
  normalizeWebhookPath,
13
14
  resolveChannel,
14
15
  resolveTelegramConfig,
@@ -20,6 +21,8 @@ export {
20
21
  type TelegramChatMember,
21
22
  type TelegramMessage,
22
23
  type TelegramOutboundAction,
24
+ type TelegramOutboundPlan,
25
+ type TelegramOutboundUpload,
23
26
  type TelegramUpdate,
24
27
  type TelegramUser,
25
28
  type TelegramWireSegment,
package/src/polling.ts CHANGED
@@ -47,11 +47,12 @@ export async function runTelegramPollLoop(
47
47
  ok: false,
48
48
  error: err instanceof Error ? err.message : String(err),
49
49
  }));
50
+ // 不在退避后清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS,
51
+ // 保持计数才能让 BACKOFF_DELAY_MS 持续生效(成功时上面才清零)。
50
52
  await sleep(
51
53
  consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ? BACKOFF_DELAY_MS : RETRY_DELAY_MS,
52
54
  abortSignal,
53
55
  );
54
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) consecutiveFailures = 0;
55
56
  }
56
57
  }
57
58
  }
package/src/protocol.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { IncomingMessage } from 'node:http';
7
+ import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
8
+ import type { Segment } from '@zhin.js/core/runtime';
7
9
 
8
10
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
9
11
  export interface TelegramAdapterConfig {
@@ -355,6 +357,108 @@ export function formatCallbackContent(query: TelegramCallbackQuery): string {
355
357
  return query.data ? `[action: ${query.data}]` : '[action]';
356
358
  }
357
359
 
360
+ /**
361
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
362
+ * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
363
+ * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
364
+ */
365
+ export function formatInboundSegments(msg: TelegramMessage): Segment[] {
366
+ const out: Segment[] = [];
367
+ if (msg.reply_to_message) {
368
+ out.push({
369
+ type: 'reply',
370
+ data: { message_id: String(msg.reply_to_message.message_id) },
371
+ });
372
+ }
373
+ const text = msg.text ?? msg.caption;
374
+ if (text) out.push({ type: 'text', data: { text } });
375
+ if (msg.photo?.length) {
376
+ const largest = msg.photo[msg.photo.length - 1]!;
377
+ out.push({
378
+ type: 'image',
379
+ data: { media: { kind: 'file', value: largest.file_id } },
380
+ });
381
+ }
382
+ if (msg.video) {
383
+ out.push({
384
+ type: 'video',
385
+ data: { media: { kind: 'file', value: msg.video.file_id } },
386
+ });
387
+ }
388
+ if (msg.audio) {
389
+ out.push({
390
+ type: 'audio',
391
+ data: {
392
+ media: { kind: 'file', value: msg.audio.file_id },
393
+ ...(msg.audio.title ? { name: msg.audio.title } : {}),
394
+ },
395
+ });
396
+ }
397
+ if (msg.voice) {
398
+ out.push({
399
+ type: 'voice',
400
+ data: { media: { kind: 'file', value: msg.voice.file_id } },
401
+ });
402
+ }
403
+ if (msg.document) {
404
+ out.push({
405
+ type: 'file',
406
+ data: {
407
+ media: {
408
+ kind: 'file',
409
+ value: msg.document.file_id,
410
+ ...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
411
+ },
412
+ ...(msg.document.file_name ? { name: msg.document.file_name } : {}),
413
+ },
414
+ });
415
+ }
416
+ if (msg.sticker) {
417
+ out.push({
418
+ type: 'image',
419
+ data: {
420
+ media: { kind: 'file', value: msg.sticker.file_id },
421
+ ...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
422
+ },
423
+ });
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /**
429
+ * callback_query → action 段(Wave 1 C interactive 约定:
430
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
431
+ * 与 formatCallbackContent / metadata.payload 同源。
432
+ */
433
+ export function formatCallbackSegments(query: TelegramCallbackQuery): Segment[] {
434
+ return [{
435
+ type: 'action',
436
+ data: {
437
+ id: query.id,
438
+ payload: query.data ?? '',
439
+ ...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
440
+ },
441
+ }];
442
+ }
443
+
444
+ /**
445
+ * 出站待上传媒体(base64 / 本地路径 MediaRef 物化为 multipart 附件)。
446
+ * params 里以 `attach://<attachName>` 占位,endpoint 发送时替换为文件 part。
447
+ */
448
+ export interface TelegramOutboundUpload {
449
+ readonly attachName: string;
450
+ readonly filename: string;
451
+ readonly source:
452
+ | { readonly kind: 'base64'; readonly data: string }
453
+ | { readonly kind: 'path'; readonly path: string };
454
+ readonly mimeType?: string;
455
+ }
456
+
457
+ export interface TelegramOutboundPlan {
458
+ readonly actions: TelegramOutboundAction[];
459
+ readonly uploads: readonly TelegramOutboundUpload[];
460
+ }
461
+
358
462
  /**
359
463
  * Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
360
464
  * Segment canonicalization is intentionally not done here.
@@ -362,6 +466,27 @@ export function formatCallbackContent(query: TelegramCallbackQuery): string {
362
466
  export function formatOutboundActions(
363
467
  target: string | number,
364
468
  payload: unknown,
469
+ ): TelegramOutboundAction[] {
470
+ return formatOutboundPlan(target, payload).actions;
471
+ }
472
+
473
+ /**
474
+ * formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
475
+ * 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
476
+ * kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
477
+ */
478
+ export function formatOutboundPlan(
479
+ target: string | number,
480
+ payload: unknown,
481
+ ): TelegramOutboundPlan {
482
+ const uploads: TelegramOutboundUpload[] = [];
483
+ return { actions: buildOutboundActions(target, payload, uploads), uploads };
484
+ }
485
+
486
+ function buildOutboundActions(
487
+ target: string | number,
488
+ payload: unknown,
489
+ uploads: TelegramOutboundUpload[],
365
490
  ): TelegramOutboundAction[] {
366
491
  const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
367
492
  if (typeof payload === 'string') {
@@ -395,11 +520,39 @@ export function formatOutboundActions(
395
520
  replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}
396
521
  );
397
522
 
398
- const mediaSource = (data: Record<string, unknown>): string | undefined => {
399
- if (typeof data.file_id === 'string' && data.file_id) return data.file_id;
400
- if (typeof data.url === 'string' && data.url) return data.url;
401
- if (typeof data.file === 'string' && data.file) return data.file;
402
- return undefined;
523
+ /**
524
+ * 媒体来源归一:canonical `data.media` 优先,旧 wire 字段
525
+ * `{file_id,url,file,base64}` 经 mediaRefFromLegacyData 兼容。
526
+ * url/file → 字符串直发;base64/path → attach:// 占位并登记上传。
527
+ */
528
+ const mediaSource = (data: Record<string, unknown>, defaultName: string): string | undefined => {
529
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
530
+ if (!media) return undefined;
531
+ if (media.kind === 'file' || media.kind === 'url') return media.value;
532
+ const named = data.name ?? data.filename;
533
+ let filename = typeof named === 'string' && named ? named : undefined;
534
+ if (!filename && media.kind === 'path') {
535
+ const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
536
+ filename = raw.split(/[\\/]/).filter(Boolean).pop();
537
+ }
538
+ const attachName = `attach${uploads.length}`;
539
+ uploads.push({
540
+ attachName,
541
+ filename: filename ?? defaultName,
542
+ source: media.kind === 'base64'
543
+ ? {
544
+ kind: 'base64',
545
+ data: media.value.startsWith('base64://')
546
+ ? media.value.slice('base64://'.length)
547
+ : media.value,
548
+ }
549
+ : {
550
+ kind: 'path',
551
+ path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
552
+ },
553
+ ...(media.mime_type ? { mimeType: media.mime_type } : {}),
554
+ });
555
+ return `attach://${attachName}`;
403
556
  };
404
557
 
405
558
  for (const item of items) {
@@ -437,7 +590,7 @@ export function formatOutboundActions(
437
590
  break;
438
591
  }
439
592
  case 'image': {
440
- const photo = mediaSource(data);
593
+ const photo = mediaSource(data, 'image.png');
441
594
  if (photo) {
442
595
  actions.push({
443
596
  method: 'sendPhoto',
@@ -453,7 +606,7 @@ export function formatOutboundActions(
453
606
  break;
454
607
  }
455
608
  case 'video': {
456
- const video = mediaSource(data);
609
+ const video = mediaSource(data, 'video.mp4');
457
610
  if (video) {
458
611
  actions.push({
459
612
  method: 'sendVideo',
@@ -469,7 +622,7 @@ export function formatOutboundActions(
469
622
  break;
470
623
  }
471
624
  case 'audio': {
472
- const audio = mediaSource(data);
625
+ const audio = mediaSource(data, 'audio.mp3');
473
626
  if (audio) {
474
627
  actions.push({
475
628
  method: 'sendAudio',
@@ -485,7 +638,7 @@ export function formatOutboundActions(
485
638
  break;
486
639
  }
487
640
  case 'voice': {
488
- const voice = mediaSource(data);
641
+ const voice = mediaSource(data, 'voice.ogg');
489
642
  if (voice) {
490
643
  actions.push({
491
644
  method: 'sendVoice',
@@ -501,7 +654,7 @@ export function formatOutboundActions(
501
654
  break;
502
655
  }
503
656
  case 'file': {
504
- const document = mediaSource(data);
657
+ const document = mediaSource(data, 'file');
505
658
  if (document) {
506
659
  actions.push({
507
660
  method: 'sendDocument',
@@ -517,7 +670,7 @@ export function formatOutboundActions(
517
670
  break;
518
671
  }
519
672
  case 'sticker': {
520
- const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data);
673
+ const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data, 'sticker.webp');
521
674
  if (sticker) {
522
675
  actions.push({
523
676
  method: 'sendSticker',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `telegram endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { telegramRuntimeStateToken } from './telegram-runtime-state.js';
8
+
9
+ export const telegramEndpointCommands = createEndpointCommands({
10
+ adapterKey: 'telegram',
11
+ adapterDisplayName: 'Telegram',
12
+ fields: [
13
+ { key: 'token', required: true, env: true, description: 'Telegram bot token' },
14
+ ],
15
+ running: (use) => use(telegramRuntimeStateToken).endpoints.values(),
16
+ describeEntry: (entry) => `token: ${String(entry.token)}`,
17
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Telegram 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `telegram endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');
package/src/webhook.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Telegram webhook HTTP: secret token → parse → handle update.
3
3
  */
4
+ import { timingSafeEqual } from 'node:crypto';
4
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
6
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
7
  import { getLogger } from '@zhin.js/logger';
@@ -8,6 +9,13 @@ import { readTextBody, type ResolvedTelegramConfig, type TelegramUpdate } from '
8
9
 
9
10
  const logger = getLogger('telegram');
10
11
 
12
+ /** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
13
+ export function safeTokenEqual(a: string, b: string): boolean {
14
+ const bufA = Buffer.from(a, 'utf8');
15
+ const bufB = Buffer.from(b, 'utf8');
16
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
17
+ }
18
+
11
19
  export interface TelegramWebhookHandler {
12
20
  readonly config: ResolvedTelegramConfig;
13
21
  readonly isOpen: boolean;
@@ -36,7 +44,7 @@ export async function handleTelegramWebhookRequest(
36
44
  if (secret) {
37
45
  const header = request.headers['x-telegram-bot-api-secret-token'];
38
46
  const token = Array.isArray(header) ? header[0] : header;
39
- if (token !== secret) {
47
+ if (!token || !safeTokenEqual(token, secret)) {
40
48
  response.writeHead(403, { 'Content-Type': 'application/json' });
41
49
  response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
42
50
  return;
package/plugin.ts DELETED
@@ -1,13 +0,0 @@
1
- import { definePlugin } from '@zhin.js/plugin-runtime';
2
- import { registerTelegramPlatformPermitChecker } from './src/platform-permit.js';
3
-
4
- export default definePlugin({
5
- name: 'telegram',
6
- metadata: {
7
- displayName: 'Telegram Bot API Adapter',
8
- },
9
- setup() {
10
- // 平台权限门禁:chat_creator / chat_administrator / pin_messages 等(agent 工具 platformPermit)
11
- return registerTelegramPlatformPermitChecker();
12
- },
13
- });