@zhin.js/adapter-telegram 6.0.0 → 6.0.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 9c997b2: 通用 endpoint 管理命令套件:`@zhin.js/adapter` 新增 `createEndpointCommands(spec, defineCommand)`——`<adapter> endpoint list / add <name> key=value... / remove <name>` 三件套,含 kv 解析、`.env` 凭据派生(`<ADAPTER>_<NAME>_<FIELD>`)、yaml 写回保留注释、master 权限门禁(通用 `isEndpointOperator`)、自定义 bindFlow 钩子。QQ 迁移至套件(行为与扫码绑定流程不变);napcat / onebot11 / onebot12 / milky / slack / telegram 接入(字段对齐各自 schema,features 补 @zhin.js/command)。
17
+ - 6cb6152: 统一消息元素通道(UNI-Channel)落地:
18
+
19
+ - **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
20
+ - **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
21
+ - **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
22
+ - **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
23
+
24
+ - Updated dependencies [cdf64e7]
25
+ - Updated dependencies [2d0a159]
26
+ - Updated dependencies [5691aba]
27
+ - Updated dependencies [078e3f7]
28
+ - Updated dependencies [50497a5]
29
+ - Updated dependencies [9c997b2]
30
+ - Updated dependencies [09d4f25]
31
+ - Updated dependencies [43485a9]
32
+ - Updated dependencies [f0ec5ab]
33
+ - Updated dependencies [3e925d0]
34
+ - Updated dependencies [fa66c4c]
35
+ - Updated dependencies [fa66c4c]
36
+ - Updated dependencies [6cb6152]
37
+ - @zhin.js/command@1.0.3
38
+ - @zhin.js/agent@1.0.6
39
+ - @zhin.js/plugin-runtime@1.1.1
40
+ - @zhin.js/host-http@1.0.3
41
+ - zhin.js@5.0.1
42
+ - @zhin.js/adapter@1.1.1
43
+ - @zhin.js/core@1.4.1
44
+
3
45
  ## 6.0.0
4
46
 
5
47
  ### Patch Changes
@@ -9,14 +9,26 @@ import {
9
9
  resolveTelegramConfig,
10
10
  type TelegramAdapterConfig,
11
11
  } from '../src/protocol.js';
12
+ import { telegramRuntimeStateToken } from '../src/telegram-runtime-state.js';
12
13
 
13
14
  export { TelegramEndpoint } from '../src/endpoint.js';
14
15
  export type { TelegramEndpointOptions, TelegramFetch } from '../src/endpoint.js';
15
16
 
16
17
  export default defineAdapter<TelegramAdapterConfig>({
17
18
  capabilities: ['inbound', 'outbound'],
19
+ // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
20
+ // inline keyboard 原生按钮承载交互段。
21
+ segments: {
22
+ outboundMedia: ['url', 'upload'],
23
+ interactive: 'native',
24
+ },
18
25
  create(context) {
19
26
  const config = resolveTelegramConfig(context.config);
27
+ // 注册到插件运行时状态(telegram endpoint list 的"运行中"数据源)
28
+ context.use(telegramRuntimeStateToken).endpoints.set(config.name, {
29
+ name: config.name,
30
+ mode: config.mode,
31
+ });
20
32
  return new TelegramEndpoint({
21
33
  id: context.id,
22
34
  gateway: context.use(messageGatewayToken),
@@ -0,0 +1,3 @@
1
+ import { telegramEndpointCommands } from '../../../src/telegram-endpoint-commands.js';
2
+
3
+ export default telegramEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { telegramEndpointCommands } from '../../src/telegram-endpoint-commands.js';
2
+
3
+ export default telegramEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { telegramEndpointCommands } from '../../../src/telegram-endpoint-commands.js';
2
+
3
+ export default telegramEndpointCommands.remove;
package/lib/endpoint.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- /**
2
- * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
3
- */
4
1
  import type { EndpointInstance } from '@zhin.js/adapter';
5
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
3
  import type { HttpHost } from '@zhin.js/host-http';
@@ -9,7 +6,7 @@ import { type ResolvedTelegramConfig, type TelegramCallbackQuery, type TelegramC
9
6
  export type TelegramFetch = (url: string, init?: {
10
7
  readonly method?: string;
11
8
  readonly headers?: Record<string, string>;
12
- readonly body?: string;
9
+ readonly body?: string | FormData;
13
10
  readonly signal?: AbortSignal;
14
11
  }) => Promise<{
15
12
  readonly ok: boolean;
@@ -24,6 +21,11 @@ export interface TelegramEndpointOptions {
24
21
  readonly http?: HttpHost;
25
22
  readonly fetch?: TelegramFetch;
26
23
  }
24
+ /**
25
+ * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
26
+ * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
27
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
28
+ */
27
29
  export declare class TelegramEndpoint implements EndpointInstance {
28
30
  #private;
29
31
  constructor(options: TelegramEndpointOptions);
@@ -48,6 +50,8 @@ export declare class TelegramEndpoint implements EndpointInstance {
48
50
  /** Used by webhook / polling handlers. */
49
51
  handleUpdate(update: TelegramUpdate): void;
50
52
  callApi<T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
53
+ /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
54
+ callApiForm<T = unknown>(method: string, form: FormData, signal?: AbortSignal): Promise<T>;
51
55
  pinMessage(chatId: number, messageId: number): Promise<boolean>;
52
56
  unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
53
57
  setChatDescription(chatId: number, description: string): Promise<boolean>;
package/lib/endpoint.js CHANGED
@@ -1,12 +1,21 @@
1
+ /**
2
+ * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
3
+ */
4
+ import { readFile } from 'node:fs/promises';
1
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
6
  import { runTelegramPollLoop } from './polling.js';
3
7
  import { normalizeTelegramChatMember } from './platform-permit.js';
4
- import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, resolveChannel, senderDisplayName, } from './protocol.js';
8
+ import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveChannel, senderDisplayName, } from './protocol.js';
5
9
  import { registerTelegramAgentEndpoint } from './telegram-agent-deps.js';
6
10
  import { registerTelegramWebhookRoutes } from './webhook.js';
7
11
  const logger = getLogger('telegram');
8
12
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
9
13
  const CHAT_MEMBER_CACHE_MAX = 2_000;
14
+ /**
15
+ * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
16
+ * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
17
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
18
+ */
10
19
  export class TelegramEndpoint {
11
20
  #options;
12
21
  #fetch;
@@ -55,6 +64,15 @@ export class TelegramEndpoint {
55
64
  }
56
65
  this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
57
66
  const webhook = this.#options.config.webhook;
67
+ if (!webhook.secretToken) {
68
+ // 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
69
+ logger.warn(formatCompact({
70
+ op: 'webhook_no_secret',
71
+ endpoint: this.#options.config.name,
72
+ path: webhook.path,
73
+ hint: 'set webhook.secretToken to authenticate Telegram callbacks',
74
+ }));
75
+ }
58
76
  const url = buildWebhookUrl(webhook);
59
77
  await this.callApi('setWebhook', {
60
78
  url,
@@ -110,15 +128,45 @@ export class TelegramEndpoint {
110
128
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
111
129
  }
112
130
  async send({ target, payload }) {
113
- const actions = formatOutboundActions(target, payload);
131
+ const plan = formatOutboundPlan(target, payload);
114
132
  let lastId = '';
115
- for (const action of actions) {
116
- const result = await this.callApi(action.method, action.params);
133
+ for (const action of plan.actions) {
134
+ const form = await this.#buildUploadForm(action.params, plan.uploads);
135
+ const result = form
136
+ ? await this.callApiForm(action.method, form)
137
+ : await this.callApi(action.method, action.params);
117
138
  if (result.message_id != null)
118
139
  lastId = String(result.message_id);
119
140
  }
120
141
  return lastId || `telegram-${Date.now()}`;
121
142
  }
143
+ /**
144
+ * 含 `attach://` 占位的媒体参数 → multipart/form-data:
145
+ * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
146
+ * (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
147
+ */
148
+ async #buildUploadForm(params, uploads) {
149
+ const values = Object.values(params);
150
+ if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://')))
151
+ return undefined;
152
+ const form = new FormData();
153
+ for (const [key, value] of Object.entries(params)) {
154
+ if (value == null)
155
+ continue;
156
+ if (typeof value === 'string' && value.startsWith('attach://')) {
157
+ const upload = uploads.find((item) => `attach://${item.attachName}` === value);
158
+ if (!upload)
159
+ throw new Error(`Telegram upload 未登记: ${value}`);
160
+ const data = upload.source.kind === 'base64'
161
+ ? Buffer.from(upload.source.data, 'base64')
162
+ : await readFile(upload.source.path);
163
+ form.append(key, new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined), upload.filename);
164
+ continue;
165
+ }
166
+ form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
167
+ }
168
+ return form;
169
+ }
122
170
  /** Test / internal: admit a message when open. */
123
171
  admit(msg) {
124
172
  if (!this.#open)
@@ -140,6 +188,7 @@ export class TelegramEndpoint {
140
188
  adapter: this.#options.id,
141
189
  target: channelId,
142
190
  content: formatInboundContent(msg),
191
+ segments: formatInboundSegments(msg),
143
192
  sender: senderDisplayName(msg.from),
144
193
  id: String(msg.message_id),
145
194
  metadata: Object.freeze({
@@ -224,6 +273,7 @@ export class TelegramEndpoint {
224
273
  adapter: this.#options.id,
225
274
  target: channelId,
226
275
  content: formatCallbackContent(query),
276
+ segments: formatCallbackSegments(query),
227
277
  sender: senderDisplayName(query.from),
228
278
  id: query.id,
229
279
  metadata: Object.freeze({
@@ -264,6 +314,19 @@ export class TelegramEndpoint {
264
314
  body: JSON.stringify(params),
265
315
  signal,
266
316
  });
317
+ return this.#parseApiResponse(method, response);
318
+ }
319
+ /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
320
+ async callApiForm(method, form, signal) {
321
+ const url = botApiUrl(this.#options.config, method);
322
+ const response = await this.#fetch(url, {
323
+ method: 'POST',
324
+ body: form,
325
+ signal,
326
+ });
327
+ return this.#parseApiResponse(method, response);
328
+ }
329
+ async #parseApiResponse(method, response) {
267
330
  const text = await response.text();
268
331
  let body;
269
332
  try {
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { TelegramEndpoint, type TelegramEndpointOptions, type TelegramFetch, } from './endpoint.js';
2
- export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, type ResolvedTelegramConfig, type TelegramAdapterConfig, type TelegramCallbackQuery, type TelegramChat, type TelegramChatMember, type TelegramMessage, type TelegramOutboundAction, type TelegramUpdate, type TelegramUser, type TelegramWireSegment, } from './protocol.js';
2
+ export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, type ResolvedTelegramConfig, type TelegramAdapterConfig, type TelegramCallbackQuery, type TelegramChat, type TelegramChatMember, type TelegramMessage, type TelegramOutboundAction, type TelegramOutboundPlan, type TelegramOutboundUpload, type TelegramUpdate, type TelegramUser, type TelegramWireSegment, } from './protocol.js';
3
3
  export { getTelegramAgentDeps, registerTelegramAgentEndpoint, setTelegramAgentDeps, type TelegramAgentDeps, type TelegramAgentEndpoint, } from './telegram-agent-deps.js';
4
4
  export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, registerTelegramPlatformPermitChecker, telegramGroupPermitResolver, } from './platform-permit.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { TelegramEndpoint, } from './endpoint.js';
2
- export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, } from './protocol.js';
2
+ export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, } from './protocol.js';
3
3
  export { getTelegramAgentDeps, registerTelegramAgentEndpoint, setTelegramAgentDeps, } from './telegram-agent-deps.js';
4
4
  export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, registerTelegramPlatformPermitChecker, telegramGroupPermitResolver, } from './platform-permit.js';
package/lib/polling.js CHANGED
@@ -31,9 +31,9 @@ export async function runTelegramPollLoop(host, abortSignal) {
31
31
  ok: false,
32
32
  error: err instanceof Error ? err.message : String(err),
33
33
  }));
34
+ // 不在退避后清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS,
35
+ // 保持计数才能让 BACKOFF_DELAY_MS 持续生效(成功时上面才清零)。
34
36
  await sleep(consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ? BACKOFF_DELAY_MS : RETRY_DELAY_MS, abortSignal);
35
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES)
36
- consecutiveFailures = 0;
37
37
  }
38
38
  }
39
39
  }
package/lib/protocol.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import type { IncomingMessage } from 'node:http';
6
+ import type { Segment } from '@zhin.js/core/runtime';
6
7
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
7
8
  export interface TelegramAdapterConfig {
8
9
  readonly name?: string;
@@ -243,8 +244,46 @@ export declare function senderDisplayName(user?: TelegramUser): string;
243
244
  /** Build inbound text for MessageGateway.receive. */
244
245
  export declare function formatInboundContent(msg: TelegramMessage): string;
245
246
  export declare function formatCallbackContent(query: TelegramCallbackQuery): string;
247
+ /**
248
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
249
+ * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
250
+ * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
251
+ */
252
+ export declare function formatInboundSegments(msg: TelegramMessage): Segment[];
253
+ /**
254
+ * callback_query → action 段(Wave 1 C interactive 约定:
255
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
256
+ * 与 formatCallbackContent / metadata.payload 同源。
257
+ */
258
+ export declare function formatCallbackSegments(query: TelegramCallbackQuery): Segment[];
259
+ /**
260
+ * 出站待上传媒体(base64 / 本地路径 MediaRef 物化为 multipart 附件)。
261
+ * params 里以 `attach://<attachName>` 占位,endpoint 发送时替换为文件 part。
262
+ */
263
+ export interface TelegramOutboundUpload {
264
+ readonly attachName: string;
265
+ readonly filename: string;
266
+ readonly source: {
267
+ readonly kind: 'base64';
268
+ readonly data: string;
269
+ } | {
270
+ readonly kind: 'path';
271
+ readonly path: string;
272
+ };
273
+ readonly mimeType?: string;
274
+ }
275
+ export interface TelegramOutboundPlan {
276
+ readonly actions: TelegramOutboundAction[];
277
+ readonly uploads: readonly TelegramOutboundUpload[];
278
+ }
246
279
  /**
247
280
  * Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
248
281
  * Segment canonicalization is intentionally not done here.
249
282
  */
250
283
  export declare function formatOutboundActions(target: string | number, payload: unknown): TelegramOutboundAction[];
284
+ /**
285
+ * formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
286
+ * 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
287
+ * kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
288
+ */
289
+ export declare function formatOutboundPlan(target: string | number, payload: unknown): TelegramOutboundPlan;
package/lib/protocol.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Telegram Bot API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
+ import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
5
6
  export function resolveTelegramConfig(config = {}) {
6
7
  const entry = config.endpoints?.find((item) => item.context === 'telegram');
7
8
  const token = config.token
@@ -112,11 +113,106 @@ export function formatInboundContent(msg) {
112
113
  export function formatCallbackContent(query) {
113
114
  return query.data ? `[action: ${query.data}]` : '[action]';
114
115
  }
116
+ /**
117
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
118
+ * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
119
+ * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
120
+ */
121
+ export function formatInboundSegments(msg) {
122
+ const out = [];
123
+ if (msg.reply_to_message) {
124
+ out.push({
125
+ type: 'reply',
126
+ data: { message_id: String(msg.reply_to_message.message_id) },
127
+ });
128
+ }
129
+ const text = msg.text ?? msg.caption;
130
+ if (text)
131
+ out.push({ type: 'text', data: { text } });
132
+ if (msg.photo?.length) {
133
+ const largest = msg.photo[msg.photo.length - 1];
134
+ out.push({
135
+ type: 'image',
136
+ data: { media: { kind: 'file', value: largest.file_id } },
137
+ });
138
+ }
139
+ if (msg.video) {
140
+ out.push({
141
+ type: 'video',
142
+ data: { media: { kind: 'file', value: msg.video.file_id } },
143
+ });
144
+ }
145
+ if (msg.audio) {
146
+ out.push({
147
+ type: 'audio',
148
+ data: {
149
+ media: { kind: 'file', value: msg.audio.file_id },
150
+ ...(msg.audio.title ? { name: msg.audio.title } : {}),
151
+ },
152
+ });
153
+ }
154
+ if (msg.voice) {
155
+ out.push({
156
+ type: 'voice',
157
+ data: { media: { kind: 'file', value: msg.voice.file_id } },
158
+ });
159
+ }
160
+ if (msg.document) {
161
+ out.push({
162
+ type: 'file',
163
+ data: {
164
+ media: {
165
+ kind: 'file',
166
+ value: msg.document.file_id,
167
+ ...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
168
+ },
169
+ ...(msg.document.file_name ? { name: msg.document.file_name } : {}),
170
+ },
171
+ });
172
+ }
173
+ if (msg.sticker) {
174
+ out.push({
175
+ type: 'image',
176
+ data: {
177
+ media: { kind: 'file', value: msg.sticker.file_id },
178
+ ...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
179
+ },
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * callback_query → action 段(Wave 1 C interactive 约定:
186
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
187
+ * 与 formatCallbackContent / metadata.payload 同源。
188
+ */
189
+ export function formatCallbackSegments(query) {
190
+ return [{
191
+ type: 'action',
192
+ data: {
193
+ id: query.id,
194
+ payload: query.data ?? '',
195
+ ...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
196
+ },
197
+ }];
198
+ }
115
199
  /**
116
200
  * Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
117
201
  * Segment canonicalization is intentionally not done here.
118
202
  */
119
203
  export function formatOutboundActions(target, payload) {
204
+ return formatOutboundPlan(target, payload).actions;
205
+ }
206
+ /**
207
+ * formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
208
+ * 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
209
+ * kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
210
+ */
211
+ export function formatOutboundPlan(target, payload) {
212
+ const uploads = [];
213
+ return { actions: buildOutboundActions(target, payload, uploads), uploads };
214
+ }
215
+ function buildOutboundActions(target, payload, uploads) {
120
216
  const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
121
217
  if (typeof payload === 'string') {
122
218
  const text = payload.trim();
@@ -144,14 +240,41 @@ export function formatOutboundActions(target, payload) {
144
240
  let keyboard;
145
241
  const actions = [];
146
242
  const replyParams = () => (replyTo != null ? { reply_parameters: { message_id: replyTo } } : {});
147
- const mediaSource = (data) => {
148
- if (typeof data.file_id === 'string' && data.file_id)
149
- return data.file_id;
150
- if (typeof data.url === 'string' && data.url)
151
- return data.url;
152
- if (typeof data.file === 'string' && data.file)
153
- return data.file;
154
- return undefined;
243
+ /**
244
+ * 媒体来源归一:canonical `data.media` 优先,旧 wire 字段
245
+ * `{file_id,url,file,base64}` 经 mediaRefFromLegacyData 兼容。
246
+ * url/file 字符串直发;base64/path attach:// 占位并登记上传。
247
+ */
248
+ const mediaSource = (data, defaultName) => {
249
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
250
+ if (!media)
251
+ return undefined;
252
+ if (media.kind === 'file' || media.kind === 'url')
253
+ return media.value;
254
+ const named = data.name ?? data.filename;
255
+ let filename = typeof named === 'string' && named ? named : undefined;
256
+ if (!filename && media.kind === 'path') {
257
+ const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
258
+ filename = raw.split(/[\\/]/).filter(Boolean).pop();
259
+ }
260
+ const attachName = `attach${uploads.length}`;
261
+ uploads.push({
262
+ attachName,
263
+ filename: filename ?? defaultName,
264
+ source: media.kind === 'base64'
265
+ ? {
266
+ kind: 'base64',
267
+ data: media.value.startsWith('base64://')
268
+ ? media.value.slice('base64://'.length)
269
+ : media.value,
270
+ }
271
+ : {
272
+ kind: 'path',
273
+ path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
274
+ },
275
+ ...(media.mime_type ? { mimeType: media.mime_type } : {}),
276
+ });
277
+ return `attach://${attachName}`;
155
278
  };
156
279
  for (const item of items) {
157
280
  if (typeof item === 'string') {
@@ -190,7 +313,7 @@ export function formatOutboundActions(target, payload) {
190
313
  break;
191
314
  }
192
315
  case 'image': {
193
- const photo = mediaSource(data);
316
+ const photo = mediaSource(data, 'image.png');
194
317
  if (photo) {
195
318
  actions.push({
196
319
  method: 'sendPhoto',
@@ -206,7 +329,7 @@ export function formatOutboundActions(target, payload) {
206
329
  break;
207
330
  }
208
331
  case 'video': {
209
- const video = mediaSource(data);
332
+ const video = mediaSource(data, 'video.mp4');
210
333
  if (video) {
211
334
  actions.push({
212
335
  method: 'sendVideo',
@@ -222,7 +345,7 @@ export function formatOutboundActions(target, payload) {
222
345
  break;
223
346
  }
224
347
  case 'audio': {
225
- const audio = mediaSource(data);
348
+ const audio = mediaSource(data, 'audio.mp3');
226
349
  if (audio) {
227
350
  actions.push({
228
351
  method: 'sendAudio',
@@ -238,7 +361,7 @@ export function formatOutboundActions(target, payload) {
238
361
  break;
239
362
  }
240
363
  case 'voice': {
241
- const voice = mediaSource(data);
364
+ const voice = mediaSource(data, 'voice.ogg');
242
365
  if (voice) {
243
366
  actions.push({
244
367
  method: 'sendVoice',
@@ -254,7 +377,7 @@ export function formatOutboundActions(target, payload) {
254
377
  break;
255
378
  }
256
379
  case 'file': {
257
- const document = mediaSource(data);
380
+ const document = mediaSource(data, 'file');
258
381
  if (document) {
259
382
  actions.push({
260
383
  method: 'sendDocument',
@@ -270,7 +393,7 @@ export function formatOutboundActions(target, payload) {
270
393
  break;
271
394
  }
272
395
  case 'sticker': {
273
- const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data);
396
+ const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data, 'sticker.webp');
274
397
  if (sticker) {
275
398
  actions.push({
276
399
  method: 'sendSticker',
@@ -0,0 +1 @@
1
+ export declare const telegramEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,16 @@
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
+ export const telegramEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'telegram',
10
+ adapterDisplayName: 'Telegram',
11
+ fields: [
12
+ { key: 'token', required: true, env: true, description: 'Telegram bot token' },
13
+ ],
14
+ running: (use) => use(telegramRuntimeStateToken).endpoints.values(),
15
+ describeEntry: (entry) => `token: ${String(entry.token)}`,
16
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const telegramRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
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
+ export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');
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.1",
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.1",
36
+ "@zhin.js/command": "1.0.3",
37
+ "@zhin.js/core": "1.4.1",
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.1",
45
+ "@zhin.js/agent": "1.0.6",
46
+ "@zhin.js/core": "1.4.1",
47
+ "@zhin.js/plugin-runtime": "1.1.1",
48
+ "zhin.js": "5.0.1"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
51
  "zhin.js": {
@@ -62,12 +63,13 @@
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.6",
67
+ "@zhin.js/host-http": "1.0.3",
68
+ "zhin.js": "5.0.1"
68
69
  },
69
70
  "files": [
70
71
  "adapters",
72
+ "commands",
71
73
  "plugin.ts",
72
74
  "schema.json",
73
75
  "src",
@@ -93,6 +95,10 @@
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.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
2
3
  import { registerTelegramPlatformPermitChecker } from './src/platform-permit.js';
4
+ import { telegramRuntimeStateToken } from './src/telegram-runtime-state.js';
3
5
 
4
6
  export default definePlugin({
5
7
  name: 'telegram',
6
8
  metadata: {
7
9
  displayName: 'Telegram Bot API Adapter',
8
10
  },
9
- setup() {
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(telegram endpoint list 的"运行中"数据源)
13
+ context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState());
10
14
  // 平台权限门禁:chat_creator / chat_administrator / pin_messages 等(agent 工具 platformPermit)
11
15
  return registerTelegramPlatformPermitChecker();
12
16
  },
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;