@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/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
8
+ installed npm plugins load on Node without TypeScript stripping. Workspace
9
+ development continues to prefer TypeScript sources for local HMR.
10
+
11
+ Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
12
+ records now use ordinary convention commands owned by the game hub plugin.
13
+
14
+ - Updated dependencies [d5cd4aa]
15
+ - @zhin.js/command@1.0.4
16
+ - zhin.js@5.0.2
17
+ - @zhin.js/adapter@1.1.2
18
+ - @zhin.js/core@1.4.2
19
+ - @zhin.js/agent@1.0.7
20
+
21
+ ## 6.0.1
22
+
23
+ ### Patch Changes
24
+
25
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
26
+
27
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
28
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
29
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
30
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
31
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
32
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
33
+
34
+ - 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)。
35
+ - 6cb6152: 统一消息元素通道(UNI-Channel)落地:
36
+
37
+ - **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
38
+ - **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
39
+ - **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
40
+ - **首批迁移**: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 图片不再静默丢图,失败降级文本)。
41
+
42
+ - Updated dependencies [cdf64e7]
43
+ - Updated dependencies [2d0a159]
44
+ - Updated dependencies [5691aba]
45
+ - Updated dependencies [078e3f7]
46
+ - Updated dependencies [50497a5]
47
+ - Updated dependencies [9c997b2]
48
+ - Updated dependencies [09d4f25]
49
+ - Updated dependencies [43485a9]
50
+ - Updated dependencies [f0ec5ab]
51
+ - Updated dependencies [3e925d0]
52
+ - Updated dependencies [fa66c4c]
53
+ - Updated dependencies [fa66c4c]
54
+ - Updated dependencies [6cb6152]
55
+ - @zhin.js/command@1.0.3
56
+ - @zhin.js/agent@1.0.6
57
+ - @zhin.js/plugin-runtime@1.1.1
58
+ - @zhin.js/host-http@1.0.3
59
+ - zhin.js@5.0.1
60
+ - @zhin.js/adapter@1.1.1
61
+ - @zhin.js/core@1.4.1
62
+
3
63
  ## 6.0.0
4
64
 
5
65
  ### Patch Changes
@@ -0,0 +1,34 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * Convention entry: discover `adapters/telegram.ts` → defineAdapter.
4
+ */
5
+ import { defineAdapter } from '@zhin.js/adapter';
6
+ import { messageGatewayToken } from '@zhin.js/core/runtime';
7
+ import { httpHostToken } from '@zhin.js/host-http';
8
+ import { TelegramEndpoint } from "../lib/endpoint.js";
9
+ import { resolveTelegramConfig, } from "../lib/protocol.js";
10
+ import { telegramRuntimeStateToken } from "../lib/telegram-runtime-state.js";
11
+ export { TelegramEndpoint } from "../lib/endpoint.js";
12
+ export default defineAdapter({
13
+ capabilities: ['inbound', 'outbound'],
14
+ // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
15
+ // inline keyboard 原生按钮承载交互段。
16
+ segments: {
17
+ outboundMedia: ['url', 'upload'],
18
+ interactive: 'native',
19
+ },
20
+ create(context) {
21
+ const config = resolveTelegramConfig(context.config);
22
+ // 注册到插件运行时状态(telegram endpoint list 的"运行中"数据源)
23
+ context.use(telegramRuntimeStateToken).endpoints.set(config.name, {
24
+ name: config.name,
25
+ mode: config.mode,
26
+ });
27
+ return new TelegramEndpoint({
28
+ id: context.id,
29
+ gateway: context.use(messageGatewayToken),
30
+ config,
31
+ http: config.mode === 'webhook' ? context.use(httpHostToken) : undefined,
32
+ });
33
+ },
34
+ });
@@ -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
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { telegramEndpointCommands } from "../../../lib/telegram-endpoint-commands.js";
3
+ export default telegramEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { telegramEndpointCommands } from '../../../src/telegram-endpoint-commands.js';
2
+
3
+ export default telegramEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { telegramEndpointCommands } from "../../lib/telegram-endpoint-commands.js";
3
+ export default telegramEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { telegramEndpointCommands } from '../../src/telegram-endpoint-commands.js';
2
+
3
+ export default telegramEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { telegramEndpointCommands } from "../../../lib/telegram-endpoint-commands.js";
3
+ export default telegramEndpointCommands.remove;
@@ -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');