@zhin.js/adapter-telegram 7.0.13 → 7.0.14

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
+ ## 7.0.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 5969c5b: Remove legacy compound-string message targets and Endpoint control probing. Endpoint control and outbound Host operations now carry structured `MessageRef` identities. Endpoint send has one exact result contract (a platform message id), which IM Runtime projects into `DeliveryReceipt.message`; arbitrary result guessing is removed.
8
+ - 974772e: Replace the user-facing `Prompt` vocabulary with the `UserInteraction` authoring surface for input, confirmation, and selection. Commands and handlers now expose `interaction`; IM Runtime exposes `createInteraction`; schema-driven endpoint collection is named `SchemaInteraction`. The old prompt-named interaction types and properties are removed rather than aliased. User interactions render through one canonical Markdown and keyboard/list presentation module shared by commands and Agent `ask_user` turns.
9
+
10
+ Extract the transport-neutral interaction contract into `@zhin.js/interaction`. A discriminated `ask()` API supports text, number, confirmation, single-select, multi-select, and typed lists with structured `title`, `description`, and `tip` content. Typed `sequence()` interactions return one result object keyed by step id, render progress, and retry invalid replies without leaking invalid values to callers.
11
+
12
+ Preserve AI Markdown and card command actions through outbound publishing. QQ delivers Markdown with native command buttons; KOOK, Discord, Telegram, DingTalk, and Lark/Feishu now declare and encode their native Markdown dialects while retaining each adapter's interaction policy. Correct QQ callback button action encoding and button style mapping.
13
+
14
+ - 5969c5b: Add SideEventGateway so adapters forward notice/request/system into HandlerIndex. HandlerContext now exposes only generation-safe capabilities and prompt ports; live Endpoint escape hatches are removed.
15
+ - 059555a: Add the canonical conversation content port for scoped Telegram media references.
16
+ Opaque Bot API file ids are materialized to bounded base64 without exposing token-bearing download URLs.
17
+ - Updated dependencies [5969c5b]
18
+ - Updated dependencies [d336a3f]
19
+ - Updated dependencies [0c82a7e]
20
+ - Updated dependencies [b9217e4]
21
+ - Updated dependencies [5969c5b]
22
+ - Updated dependencies [5969c5b]
23
+ - Updated dependencies [974772e]
24
+ - Updated dependencies [5969c5b]
25
+ - Updated dependencies [5969c5b]
26
+ - Updated dependencies [2f786bd]
27
+ - Updated dependencies [63d89f9]
28
+ - Updated dependencies [71c7cdd]
29
+ - Updated dependencies [3cca0ea]
30
+ - Updated dependencies [1312ca0]
31
+ - Updated dependencies [985fa22]
32
+ - Updated dependencies [04b861d]
33
+ - Updated dependencies [a23d544]
34
+ - Updated dependencies [8cddabf]
35
+ - Updated dependencies [dbe5081]
36
+ - @zhin.js/im-contract@1.0.4
37
+ - @zhin.js/core@1.5.12
38
+ - @zhin.js/adapter@1.1.11
39
+ - @zhin.js/agent@1.1.14
40
+ - @zhin.js/host-http@1.0.11
41
+ - @zhin.js/command@1.0.15
42
+ - zhin.js@6.0.12
43
+ - @zhin.js/permission@1.0.3
44
+
3
45
  ## 7.0.13
4
46
 
5
47
  ### Patch Changes
package/README.md CHANGED
@@ -7,7 +7,7 @@ Zhin.js Telegram Bot API 适配器(Plugin Runtime),默认通过 **长轮
7
7
  - 长轮询 `getUpdates` 入站(默认;无需公网 IP / host-http)
8
8
  - 解析 text / image / video / audio / voice / document / sticker / location / callback_query
9
9
  - 支持私聊与群组
10
- - 出站 `send({ conversation, payload })` → Bot API(text / media / keyboard)
10
+ - 出站 `send({ conversation, payload })` → Bot API(Markdown→安全 HTML / media / keyboard)
11
11
  - 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
12
12
  - Webhook 模式延期(需 `httpHostToken`);配置 `polling: false` 会明确报错
13
13
 
@@ -78,6 +78,7 @@ plugins:
78
78
  | Telegram | 入站 content(文本摘要) | 出站 wire |
79
79
  |----------|--------------------------|-----------|
80
80
  | text | 原文 | sendMessage |
81
+ | markdown | 原文 | sendMessage(`parse_mode: HTML`) |
81
82
  | photo | `[image]` / caption | sendPhoto(`file_id` / `url`) |
82
83
  | video | `[video]` | sendVideo |
83
84
  | audio / voice | `[audio]` / `[voice]` | sendAudio / sendVoice |
@@ -3,7 +3,7 @@
3
3
  * Convention entry: discover `adapters/telegram.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken } from '@zhin.js/core/runtime';
6
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
7
  import { httpHostToken } from '@zhin.js/host-http';
8
8
  import { TelegramEndpoint } from "../lib/endpoint.js";
9
9
  import { resolveTelegramConfig, } from "../lib/protocol.js";
@@ -17,6 +17,7 @@ export default defineAdapter({
17
17
  segments: {
18
18
  outboundMedia: ['url', 'upload'],
19
19
  interactive: 'native',
20
+ markdown: 'native',
20
21
  },
21
22
  create(context) {
22
23
  const config = resolveTelegramConfig(context.config);
@@ -28,6 +29,7 @@ export default defineAdapter({
28
29
  return new TelegramEndpoint({
29
30
  id: context.id,
30
31
  gateway: context.use(messageGatewayToken),
32
+ sideEvents: context.use(sideEventGatewayToken),
31
33
  config,
32
34
  http: config.mode === 'webhook' ? context.use(httpHostToken) : undefined,
33
35
  });
@@ -2,7 +2,7 @@
2
2
  * Convention entry: discover `adapters/telegram.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken } from '@zhin.js/core/runtime';
5
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
6
  import { httpHostToken } from '@zhin.js/host-http';
7
7
  import { TelegramEndpoint } from '../src/endpoint.js';
8
8
  import {
@@ -22,6 +22,7 @@ export default defineAdapter<TelegramAdapterConfig>({
22
22
  segments: {
23
23
  outboundMedia: ['url', 'upload'],
24
24
  interactive: 'native',
25
+ markdown: 'native',
25
26
  },
26
27
  create(context) {
27
28
  const config = resolveTelegramConfig(context.config);
@@ -33,6 +34,7 @@ export default defineAdapter<TelegramAdapterConfig>({
33
34
  return new TelegramEndpoint({
34
35
  id: context.id,
35
36
  gateway: context.use(messageGatewayToken),
37
+ sideEvents: context.use(sideEventGatewayToken),
36
38
  config,
37
39
  http: config.mode === 'webhook' ? context.use(httpHostToken) : undefined,
38
40
  });
package/lib/endpoint.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { EndpointControl, EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway } from '@zhin.js/core/runtime';
1
+ import type { EndpointContentPort, EndpointControl, EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
2
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import { type MessageRef } from '@zhin.js/im-contract';
5
5
  import type { CapabilityId } from 'zhin.js';
@@ -14,10 +14,15 @@ export type TelegramFetch = (url: string, init?: {
14
14
  readonly status: number;
15
15
  text(): Promise<string>;
16
16
  json(): Promise<unknown>;
17
+ arrayBuffer?(): Promise<ArrayBuffer>;
18
+ readonly headers?: {
19
+ get(name: string): string | null;
20
+ };
17
21
  }>;
18
22
  export interface TelegramEndpointOptions {
19
23
  readonly id: CapabilityId;
20
24
  readonly gateway: MessageGateway;
25
+ readonly sideEvents?: SideEventGateway;
21
26
  readonly config: ResolvedTelegramConfig;
22
27
  readonly http?: HttpHost;
23
28
  readonly fetch?: TelegramFetch;
@@ -30,6 +35,7 @@ export interface TelegramEndpointOptions {
30
35
  export declare class TelegramEndpoint implements EndpointInstance {
31
36
  #private;
32
37
  readonly control: EndpointControl;
38
+ readonly content: EndpointContentPort;
33
39
  constructor(options: TelegramEndpointOptions);
34
40
  /** Used by webhook handler. */
35
41
  get isOpen(): boolean;
package/lib/endpoint.js CHANGED
@@ -32,6 +32,9 @@ export class TelegramEndpoint {
32
32
  control = Object.freeze({
33
33
  recall: (message) => this.recallMessage(message),
34
34
  });
35
+ content = Object.freeze({
36
+ resolve: (reference, context) => this.#resolveContent(reference, context.signal),
37
+ });
35
38
  constructor(options) {
36
39
  this.#logger = getAdapterLogger('telegram', options.config.id);
37
40
  this.#options = options;
@@ -152,6 +155,50 @@ export class TelegramEndpoint {
152
155
  message_id: Number(message.id),
153
156
  });
154
157
  }
158
+ async #resolveContent(reference, signal) {
159
+ if (reference.kind !== 'media') {
160
+ return Object.freeze({ status: 'unsupported', code: 'telegram_message_lookup_unavailable' });
161
+ }
162
+ if (reference.media.kind !== 'file') {
163
+ return Object.freeze({ status: 'resolved', reference, value: reference.media });
164
+ }
165
+ try {
166
+ signal.throwIfAborted();
167
+ const file = await this.callApi('getFile', {
168
+ file_id: reference.media.value,
169
+ });
170
+ if (!file.file_path)
171
+ return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
172
+ if ((file.file_size ?? 0) > 26_214_400)
173
+ return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
174
+ const response = await this.#fetch(`${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`, { signal });
175
+ if (!response.ok)
176
+ return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
177
+ if (!response.arrayBuffer)
178
+ return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
179
+ const bytes = Buffer.from(await response.arrayBuffer());
180
+ if (bytes.byteLength > 26_214_400)
181
+ return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
182
+ return Object.freeze({
183
+ status: 'resolved',
184
+ reference,
185
+ value: Object.freeze({
186
+ kind: 'base64',
187
+ value: bytes.toString('base64'),
188
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
189
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
190
+ : {}),
191
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
192
+ size: bytes.byteLength,
193
+ }),
194
+ });
195
+ }
196
+ catch (error) {
197
+ if (signal.aborted)
198
+ return Object.freeze({ status: 'expired', code: 'turn_aborted' });
199
+ return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: error instanceof Error ? error.message : String(error) });
200
+ }
201
+ }
155
202
  /**
156
203
  * 含 `attach://` 占位的媒体参数 → multipart/form-data:
157
204
  * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Canonical Markdown → Telegram Bot API HTML.
3
+ *
4
+ * Telegram's MarkdownV2 is not CommonMark-compatible and rejects unescaped
5
+ * punctuation. HTML gives the adapter a safer dialect seam: user text is
6
+ * escaped first and only the subset supported by Bot API is emitted as tags.
7
+ */
8
+ export declare function markdownToTelegramHtml(markdown: string): string;
9
+ export declare function escapeTelegramHtml(value: string): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Canonical Markdown → Telegram Bot API HTML.
3
+ *
4
+ * Telegram's MarkdownV2 is not CommonMark-compatible and rejects unescaped
5
+ * punctuation. HTML gives the adapter a safer dialect seam: user text is
6
+ * escaped first and only the subset supported by Bot API is emitted as tags.
7
+ */
8
+ export function markdownToTelegramHtml(markdown) {
9
+ const protectedFragments = [];
10
+ const protect = (html) => {
11
+ const token = `\uE000${protectedFragments.length}\uE001`;
12
+ protectedFragments.push(html);
13
+ return token;
14
+ };
15
+ let value = protectTelegramFencedCode(markdown, protect);
16
+ value = value.replace(/`([^`\n]+)`/g, (_match, code) => (protect(`<code>${escapeTelegramHtml(String(code))}</code>`)));
17
+ value = escapeTelegramHtml(value);
18
+ value = value
19
+ .replace(/!\[([^\[\]]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, '$1')
20
+ .replace(/\[([^\[\]]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, label, href) => isSafeTelegramHref(href)
21
+ ? `<a href="${href}">${label}</a>`
22
+ : label)
23
+ .replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
24
+ .replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>')
25
+ .replace(/^\s*[-+*]\s+(.+)$/gm, '• $1')
26
+ .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
27
+ .replace(/__([^_\n]+)__/g, '<b>$1</b>')
28
+ .replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
29
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<i>$2</i>')
30
+ .replace(/(^|[^_])_([^_\n]+)_/g, '$1<i>$2</i>');
31
+ for (let index = 0; index < protectedFragments.length; index += 1) {
32
+ value = value.replace(`\uE000${index}\uE001`, protectedFragments[index]);
33
+ }
34
+ return value;
35
+ }
36
+ function protectTelegramFencedCode(markdown, protect) {
37
+ const chunks = [];
38
+ let cursor = 0;
39
+ while (cursor < markdown.length) {
40
+ const opening = markdown.indexOf('```', cursor);
41
+ if (opening < 0) {
42
+ chunks.push(markdown.slice(cursor));
43
+ break;
44
+ }
45
+ chunks.push(markdown.slice(cursor, opening));
46
+ const contentStart = opening + 3;
47
+ const closing = markdown.indexOf('```', contentStart);
48
+ if (closing < 0) {
49
+ chunks.push(markdown.slice(opening));
50
+ break;
51
+ }
52
+ const newline = markdown.indexOf('\n', contentStart);
53
+ const hasHeader = newline >= 0 && newline < closing;
54
+ const language = markdown.slice(contentStart, hasHeader ? newline : closing).trim();
55
+ let code = hasHeader ? markdown.slice(newline + 1, closing) : '';
56
+ if (code.endsWith('\n'))
57
+ code = code.slice(0, -1);
58
+ const escapedCode = escapeTelegramHtml(code);
59
+ chunks.push(/^[A-Za-z0-9_+-]+$/.test(language)
60
+ ? protect(`<pre><code class="language-${language}">${escapedCode}</code></pre>`)
61
+ : protect(`<pre>${escapedCode}</pre>`));
62
+ cursor = closing + 3;
63
+ }
64
+ return chunks.join('');
65
+ }
66
+ function isSafeTelegramHref(value) {
67
+ return /^(?:https?:\/\/|tg:\/\/|mailto:)[^\s<>]+$/iu.test(value);
68
+ }
69
+ export function escapeTelegramHtml(value) {
70
+ return value
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;');
75
+ }
package/lib/protocol.d.ts CHANGED
@@ -153,6 +153,7 @@ export type TelegramOutboundAction = {
153
153
  readonly params: {
154
154
  readonly chat_id: number | string;
155
155
  readonly text: string;
156
+ readonly parse_mode?: 'HTML';
156
157
  readonly reply_parameters?: {
157
158
  readonly message_id: number;
158
159
  };
@@ -166,6 +167,7 @@ export type TelegramOutboundAction = {
166
167
  readonly chat_id: number | string;
167
168
  readonly photo: string;
168
169
  readonly caption?: string;
170
+ readonly parse_mode?: 'HTML';
169
171
  readonly reply_parameters?: {
170
172
  readonly message_id: number;
171
173
  };
@@ -176,6 +178,7 @@ export type TelegramOutboundAction = {
176
178
  readonly chat_id: number | string;
177
179
  readonly video: string;
178
180
  readonly caption?: string;
181
+ readonly parse_mode?: 'HTML';
179
182
  readonly reply_parameters?: {
180
183
  readonly message_id: number;
181
184
  };
@@ -186,6 +189,7 @@ export type TelegramOutboundAction = {
186
189
  readonly chat_id: number | string;
187
190
  readonly audio: string;
188
191
  readonly caption?: string;
192
+ readonly parse_mode?: 'HTML';
189
193
  readonly reply_parameters?: {
190
194
  readonly message_id: number;
191
195
  };
@@ -196,6 +200,7 @@ export type TelegramOutboundAction = {
196
200
  readonly chat_id: number | string;
197
201
  readonly voice: string;
198
202
  readonly caption?: string;
203
+ readonly parse_mode?: 'HTML';
199
204
  readonly reply_parameters?: {
200
205
  readonly message_id: number;
201
206
  };
@@ -206,6 +211,7 @@ export type TelegramOutboundAction = {
206
211
  readonly chat_id: number | string;
207
212
  readonly document: string;
208
213
  readonly caption?: string;
214
+ readonly parse_mode?: 'HTML';
209
215
  readonly reply_parameters?: {
210
216
  readonly message_id: number;
211
217
  };
@@ -249,7 +255,7 @@ export declare function senderDisplayName(user?: TelegramUser): string;
249
255
  export declare function formatInboundContent(msg: TelegramMessage): string;
250
256
  export declare function formatCallbackContent(query: TelegramCallbackQuery): string;
251
257
  /**
252
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
258
+ * 入站消息 → canonical Segment[];媒体只存在于 canonical 段,不重复写入文本。
253
259
  * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
254
260
  * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
255
261
  */
package/lib/protocol.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { isMediaRef } from '@zhin.js/core';
6
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
7
8
  const logger = getLogger('telegram');
8
9
  export function resolveTelegramConfig(config = {}) {
9
10
  const entry = config.endpoints?.find((item) => item.context === 'telegram');
@@ -106,17 +107,6 @@ export function formatInboundContent(msg) {
106
107
  return msg.text;
107
108
  if (msg.caption)
108
109
  return msg.caption;
109
- if (msg.photo?.length)
110
- return '[image]';
111
- if (msg.video)
112
- return '[video]';
113
- if (msg.audio)
114
- return '[audio]';
115
- if (msg.voice)
116
- return '[voice]';
117
- if (msg.document) {
118
- return msg.document.file_name ? `[file: ${msg.document.file_name}]` : '[file]';
119
- }
120
110
  if (msg.sticker) {
121
111
  return msg.sticker.emoji ? `[sticker: ${msg.sticker.emoji}]` : '[sticker]';
122
112
  }
@@ -129,7 +119,7 @@ export function formatCallbackContent(query) {
129
119
  return query.data ? `[action: ${query.data}]` : '[action]';
130
120
  }
131
121
  /**
132
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
122
+ * 入站消息 → canonical Segment[];媒体只存在于 canonical 段,不重复写入文本。
133
123
  * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
134
124
  * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
135
125
  */
@@ -240,6 +230,11 @@ function buildOutboundActions(target, payload, uploads) {
240
230
  : payload && typeof payload === 'object' && 'type' in payload
241
231
  ? [payload]
242
232
  : [];
233
+ const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
234
+ const appendPlain = (value) => hasMarkdown
235
+ ? escapeTelegramHtml(String(value ?? ''))
236
+ : String(value ?? '');
237
+ const htmlMode = hasMarkdown ? { parse_mode: 'HTML' } : {};
243
238
  if (items.length === 0) {
244
239
  const text = payload == null
245
240
  ? ''
@@ -300,17 +295,20 @@ function buildOutboundActions(target, payload, uploads) {
300
295
  };
301
296
  for (const item of items) {
302
297
  if (typeof item === 'string') {
303
- textContent += item;
298
+ textContent += appendPlain(item);
304
299
  continue;
305
300
  }
306
301
  const data = item.data ?? {};
307
302
  switch (item.type) {
308
303
  case 'text':
309
- textContent += String(data.text ?? data.content ?? '');
304
+ textContent += appendPlain(data.text ?? data.content ?? '');
305
+ break;
306
+ case 'markdown':
307
+ textContent += markdownToTelegramHtml(String(data.content ?? data.text ?? ''));
310
308
  break;
311
309
  case 'at':
312
310
  if (data.id)
313
- textContent += `@${String(data.name || data.id)}`;
311
+ textContent += `@${appendPlain(data.name || data.id)}`;
314
312
  break;
315
313
  case 'reply': {
316
314
  const id = Number(data.id ?? data.message_id);
@@ -343,6 +341,7 @@ function buildOutboundActions(target, payload, uploads) {
343
341
  chat_id: chatId,
344
342
  photo,
345
343
  caption: textContent.trim() || undefined,
344
+ ...(textContent.trim() ? htmlMode : {}),
346
345
  ...replyParams(),
347
346
  },
348
347
  });
@@ -359,6 +358,7 @@ function buildOutboundActions(target, payload, uploads) {
359
358
  chat_id: chatId,
360
359
  video,
361
360
  caption: textContent.trim() || undefined,
361
+ ...(textContent.trim() ? htmlMode : {}),
362
362
  ...replyParams(),
363
363
  },
364
364
  });
@@ -375,6 +375,7 @@ function buildOutboundActions(target, payload, uploads) {
375
375
  chat_id: chatId,
376
376
  audio,
377
377
  caption: textContent.trim() || undefined,
378
+ ...(textContent.trim() ? htmlMode : {}),
378
379
  ...replyParams(),
379
380
  },
380
381
  });
@@ -391,6 +392,7 @@ function buildOutboundActions(target, payload, uploads) {
391
392
  chat_id: chatId,
392
393
  voice,
393
394
  caption: textContent.trim() || undefined,
395
+ ...(textContent.trim() ? htmlMode : {}),
394
396
  ...replyParams(),
395
397
  },
396
398
  });
@@ -407,6 +409,7 @@ function buildOutboundActions(target, payload, uploads) {
407
409
  chat_id: chatId,
408
410
  document,
409
411
  caption: textContent.trim() || undefined,
412
+ ...(textContent.trim() ? htmlMode : {}),
410
413
  ...replyParams(),
411
414
  },
412
415
  });
@@ -437,7 +440,7 @@ function buildOutboundActions(target, payload, uploads) {
437
440
  break;
438
441
  }
439
442
  default:
440
- textContent += String(data.text ?? `[${item.type}]`);
443
+ textContent += appendPlain(data.text ?? `[${item.type}]`);
441
444
  }
442
445
  }
443
446
  if (actions.length === 0) {
@@ -449,6 +452,7 @@ function buildOutboundActions(target, payload, uploads) {
449
452
  params: {
450
453
  chat_id: chatId,
451
454
  text: text || ' ',
455
+ ...htmlMode,
452
456
  ...replyParams(),
453
457
  ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
454
458
  },
@@ -460,6 +464,7 @@ function buildOutboundActions(target, payload, uploads) {
460
464
  params: {
461
465
  chat_id: chatId,
462
466
  text: textContent.trim() || ' ',
467
+ ...htmlMode,
463
468
  ...replyParams(),
464
469
  ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
465
470
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-telegram",
3
- "version": "7.0.13",
3
+ "version": "7.0.14",
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,20 +32,20 @@
32
32
  "directory": "plugins/adapters/telegram"
33
33
  },
34
34
  "dependencies": {
35
- "@zhin.js/adapter": "1.1.10",
36
- "@zhin.js/core": "1.5.11",
37
- "@zhin.js/host-http": "1.0.10",
38
- "@zhin.js/im-contract": "1.0.3",
35
+ "@zhin.js/adapter": "1.1.11",
36
+ "@zhin.js/core": "1.5.12",
37
+ "@zhin.js/host-http": "1.0.11",
38
+ "@zhin.js/im-contract": "1.0.4",
39
39
  "@zhin.js/logger": "1.0.76"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "zod": "^4.0.0",
43
- "@zhin.js/adapter": "1.1.10",
44
- "@zhin.js/agent": "1.1.13",
45
- "@zhin.js/command": "1.0.14",
46
- "@zhin.js/core": "1.5.11",
47
- "@zhin.js/permission": "1.0.2",
48
- "zhin.js": "6.0.11"
43
+ "@zhin.js/adapter": "1.1.11",
44
+ "@zhin.js/agent": "1.1.14",
45
+ "@zhin.js/command": "1.0.15",
46
+ "@zhin.js/core": "1.5.12",
47
+ "@zhin.js/permission": "1.0.3",
48
+ "zhin.js": "6.0.12"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "@zhin.js/agent": {
@@ -66,9 +66,9 @@
66
66
  "typescript": "^6.0.3",
67
67
  "vitest": "^4.1.10",
68
68
  "zod": "^4.4.3",
69
- "@zhin.js/agent": "1.1.13",
70
- "@zhin.js/host-http": "1.0.10",
71
- "zhin.js": "6.0.11"
69
+ "@zhin.js/agent": "1.1.14",
70
+ "@zhin.js/host-http": "1.0.11",
71
+ "zhin.js": "6.0.12"
72
72
  },
73
73
  "files": [
74
74
  "adapters",
package/src/endpoint.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
3
3
  */
4
4
  import { readFile } from 'node:fs/promises';
5
- import type { EndpointControl, EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
6
- import type { MessageGateway } from '@zhin.js/core/runtime';
5
+ import type { EndpointContentPort, EndpointContentResolveContext, EndpointControl, EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
6
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
8
  import {
9
9
  type ConversationRef,
10
+ type ConversationReference,
11
+ type ConversationResolution,
10
12
  type MessageRef,
11
13
  } from '@zhin.js/im-contract';
12
14
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
@@ -56,11 +58,14 @@ export type TelegramFetch = (
56
58
  readonly status: number;
57
59
  text(): Promise<string>;
58
60
  json(): Promise<unknown>;
61
+ arrayBuffer?(): Promise<ArrayBuffer>;
62
+ readonly headers?: { get(name: string): string | null };
59
63
  }>;
60
64
 
61
65
  export interface TelegramEndpointOptions {
62
66
  readonly id: CapabilityId;
63
67
  readonly gateway: MessageGateway;
68
+ readonly sideEvents?: SideEventGateway;
64
69
  readonly config: ResolvedTelegramConfig;
65
70
  readonly http?: HttpHost;
66
71
  readonly fetch?: TelegramFetch;
@@ -100,6 +105,9 @@ export class TelegramEndpoint implements EndpointInstance {
100
105
  readonly control: EndpointControl = Object.freeze({
101
106
  recall: (message: MessageRef) => this.recallMessage(message),
102
107
  });
108
+ readonly content: EndpointContentPort = Object.freeze({
109
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) => this.#resolveContent(reference, context.signal),
110
+ });
103
111
 
104
112
  constructor(options: TelegramEndpointOptions) {
105
113
  this.#logger = getAdapterLogger('telegram', options.config.id);
@@ -229,6 +237,50 @@ export class TelegramEndpoint implements EndpointInstance {
229
237
  });
230
238
  }
231
239
 
240
+ async #resolveContent(
241
+ reference: Parameters<EndpointContentPort['resolve']>[0],
242
+ signal: AbortSignal,
243
+ ): Promise<ConversationResolution> {
244
+ if (reference.kind !== 'media') {
245
+ return Object.freeze({ status: 'unsupported', code: 'telegram_message_lookup_unavailable' });
246
+ }
247
+ if (reference.media.kind !== 'file') {
248
+ return Object.freeze({ status: 'resolved', reference, value: reference.media });
249
+ }
250
+ try {
251
+ signal.throwIfAborted();
252
+ const file = await this.callApi<{ file_path?: string; file_size?: number }>('getFile', {
253
+ file_id: reference.media.value,
254
+ });
255
+ if (!file.file_path) return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
256
+ if ((file.file_size ?? 0) > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
257
+ const response = await this.#fetch(
258
+ `${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`,
259
+ { signal },
260
+ );
261
+ if (!response.ok) return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
262
+ if (!response.arrayBuffer) return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
263
+ const bytes = Buffer.from(await response.arrayBuffer());
264
+ if (bytes.byteLength > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
265
+ return Object.freeze({
266
+ status: 'resolved',
267
+ reference,
268
+ value: Object.freeze({
269
+ kind: 'base64',
270
+ value: bytes.toString('base64'),
271
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
272
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
273
+ : {}),
274
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
275
+ size: bytes.byteLength,
276
+ }),
277
+ });
278
+ } catch (error) {
279
+ if (signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
280
+ return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: error instanceof Error ? error.message : String(error) });
281
+ }
282
+ }
283
+
232
284
  /**
233
285
  * 含 `attach://` 占位的媒体参数 → multipart/form-data:
234
286
  * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Canonical Markdown → Telegram Bot API HTML.
3
+ *
4
+ * Telegram's MarkdownV2 is not CommonMark-compatible and rejects unescaped
5
+ * punctuation. HTML gives the adapter a safer dialect seam: user text is
6
+ * escaped first and only the subset supported by Bot API is emitted as tags.
7
+ */
8
+ export function markdownToTelegramHtml(markdown: string): string {
9
+ const protectedFragments: string[] = [];
10
+ const protect = (html: string): string => {
11
+ const token = `\uE000${protectedFragments.length}\uE001`;
12
+ protectedFragments.push(html);
13
+ return token;
14
+ };
15
+
16
+ let value = protectTelegramFencedCode(markdown, protect);
17
+ value = value.replace(/`([^`\n]+)`/g, (_match, code) => (
18
+ protect(`<code>${escapeTelegramHtml(String(code))}</code>`)
19
+ ));
20
+ value = escapeTelegramHtml(value);
21
+
22
+ value = value
23
+ .replace(/!\[([^\[\]]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, '$1')
24
+ .replace(/\[([^\[\]]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
25
+ (_match, label: string, href: string) => isSafeTelegramHref(href)
26
+ ? `<a href="${href}">${label}</a>`
27
+ : label)
28
+ .replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
29
+ .replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>')
30
+ .replace(/^\s*[-+*]\s+(.+)$/gm, '• $1')
31
+ .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
32
+ .replace(/__([^_\n]+)__/g, '<b>$1</b>')
33
+ .replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
34
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<i>$2</i>')
35
+ .replace(/(^|[^_])_([^_\n]+)_/g, '$1<i>$2</i>');
36
+
37
+ for (let index = 0; index < protectedFragments.length; index += 1) {
38
+ value = value.replace(`\uE000${index}\uE001`, protectedFragments[index]!);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function protectTelegramFencedCode(
44
+ markdown: string,
45
+ protect: (html: string) => string,
46
+ ): string {
47
+ const chunks: string[] = [];
48
+ let cursor = 0;
49
+ while (cursor < markdown.length) {
50
+ const opening = markdown.indexOf('```', cursor);
51
+ if (opening < 0) {
52
+ chunks.push(markdown.slice(cursor));
53
+ break;
54
+ }
55
+ chunks.push(markdown.slice(cursor, opening));
56
+ const contentStart = opening + 3;
57
+ const closing = markdown.indexOf('```', contentStart);
58
+ if (closing < 0) {
59
+ chunks.push(markdown.slice(opening));
60
+ break;
61
+ }
62
+ const newline = markdown.indexOf('\n', contentStart);
63
+ const hasHeader = newline >= 0 && newline < closing;
64
+ const language = markdown.slice(contentStart, hasHeader ? newline : closing).trim();
65
+ let code = hasHeader ? markdown.slice(newline + 1, closing) : '';
66
+ if (code.endsWith('\n')) code = code.slice(0, -1);
67
+ const escapedCode = escapeTelegramHtml(code);
68
+ chunks.push(/^[A-Za-z0-9_+-]+$/.test(language)
69
+ ? protect(`<pre><code class="language-${language}">${escapedCode}</code></pre>`)
70
+ : protect(`<pre>${escapedCode}</pre>`));
71
+ cursor = closing + 3;
72
+ }
73
+ return chunks.join('');
74
+ }
75
+
76
+ function isSafeTelegramHref(value: string): boolean {
77
+ return /^(?:https?:\/\/|tg:\/\/|mailto:)[^\s<>]+$/iu.test(value);
78
+ }
79
+
80
+ export function escapeTelegramHtml(value: string): string {
81
+ return value
82
+ .replace(/&/g, '&amp;')
83
+ .replace(/</g, '&lt;')
84
+ .replace(/>/g, '&gt;')
85
+ .replace(/"/g, '&quot;');
86
+ }
package/src/protocol.ts CHANGED
@@ -8,6 +8,7 @@ import { isMediaRef } from '@zhin.js/core';
8
8
  import type { Segment } from '@zhin.js/core/runtime';
9
9
  import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
10
10
  import { formatCompact, getLogger } from '@zhin.js/logger';
11
+ import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
11
12
 
12
13
  const logger = getLogger('telegram');
13
14
 
@@ -172,6 +173,7 @@ export type TelegramOutboundAction =
172
173
  readonly params: {
173
174
  readonly chat_id: number | string;
174
175
  readonly text: string;
176
+ readonly parse_mode?: 'HTML';
175
177
  readonly reply_parameters?: { readonly message_id: number };
176
178
  readonly reply_markup?: { readonly inline_keyboard: TelegramInlineButton[][] };
177
179
  };
@@ -182,6 +184,7 @@ export type TelegramOutboundAction =
182
184
  readonly chat_id: number | string;
183
185
  readonly photo: string;
184
186
  readonly caption?: string;
187
+ readonly parse_mode?: 'HTML';
185
188
  readonly reply_parameters?: { readonly message_id: number };
186
189
  };
187
190
  }
@@ -191,6 +194,7 @@ export type TelegramOutboundAction =
191
194
  readonly chat_id: number | string;
192
195
  readonly video: string;
193
196
  readonly caption?: string;
197
+ readonly parse_mode?: 'HTML';
194
198
  readonly reply_parameters?: { readonly message_id: number };
195
199
  };
196
200
  }
@@ -200,6 +204,7 @@ export type TelegramOutboundAction =
200
204
  readonly chat_id: number | string;
201
205
  readonly audio: string;
202
206
  readonly caption?: string;
207
+ readonly parse_mode?: 'HTML';
203
208
  readonly reply_parameters?: { readonly message_id: number };
204
209
  };
205
210
  }
@@ -209,6 +214,7 @@ export type TelegramOutboundAction =
209
214
  readonly chat_id: number | string;
210
215
  readonly voice: string;
211
216
  readonly caption?: string;
217
+ readonly parse_mode?: 'HTML';
212
218
  readonly reply_parameters?: { readonly message_id: number };
213
219
  };
214
220
  }
@@ -218,6 +224,7 @@ export type TelegramOutboundAction =
218
224
  readonly chat_id: number | string;
219
225
  readonly document: string;
220
226
  readonly caption?: string;
227
+ readonly parse_mode?: 'HTML';
221
228
  readonly reply_parameters?: { readonly message_id: number };
222
229
  };
223
230
  }
@@ -355,13 +362,6 @@ export function senderDisplayName(user?: TelegramUser): string {
355
362
  export function formatInboundContent(msg: TelegramMessage): string {
356
363
  if (msg.text) return msg.text;
357
364
  if (msg.caption) return msg.caption;
358
- if (msg.photo?.length) return '[image]';
359
- if (msg.video) return '[video]';
360
- if (msg.audio) return '[audio]';
361
- if (msg.voice) return '[voice]';
362
- if (msg.document) {
363
- return msg.document.file_name ? `[file: ${msg.document.file_name}]` : '[file]';
364
- }
365
365
  if (msg.sticker) {
366
366
  return msg.sticker.emoji ? `[sticker: ${msg.sticker.emoji}]` : '[sticker]';
367
367
  }
@@ -376,7 +376,7 @@ export function formatCallbackContent(query: TelegramCallbackQuery): string {
376
376
  }
377
377
 
378
378
  /**
379
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
379
+ * 入站消息 → canonical Segment[];媒体只存在于 canonical 段,不重复写入文本。
380
380
  * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
381
381
  * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
382
382
  */
@@ -518,6 +518,11 @@ function buildOutboundActions(
518
518
  : payload && typeof payload === 'object' && 'type' in (payload as object)
519
519
  ? [payload as TelegramWireSegment]
520
520
  : [];
521
+ const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
522
+ const appendPlain = (value: unknown): string => hasMarkdown
523
+ ? escapeTelegramHtml(String(value ?? ''))
524
+ : String(value ?? '');
525
+ const htmlMode = hasMarkdown ? { parse_mode: 'HTML' as const } : {};
521
526
 
522
527
  if (items.length === 0) {
523
528
  const text = payload == null
@@ -583,16 +588,19 @@ function buildOutboundActions(
583
588
 
584
589
  for (const item of items) {
585
590
  if (typeof item === 'string') {
586
- textContent += item;
591
+ textContent += appendPlain(item);
587
592
  continue;
588
593
  }
589
594
  const data = item.data ?? {};
590
595
  switch (item.type) {
591
596
  case 'text':
592
- textContent += String(data.text ?? data.content ?? '');
597
+ textContent += appendPlain(data.text ?? data.content ?? '');
598
+ break;
599
+ case 'markdown':
600
+ textContent += markdownToTelegramHtml(String(data.content ?? data.text ?? ''));
593
601
  break;
594
602
  case 'at':
595
- if (data.id) textContent += `@${String(data.name || data.id)}`;
603
+ if (data.id) textContent += `@${appendPlain(data.name || data.id)}`;
596
604
  break;
597
605
  case 'reply': {
598
606
  const id = Number(data.id ?? data.message_id);
@@ -624,6 +632,7 @@ function buildOutboundActions(
624
632
  chat_id: chatId,
625
633
  photo,
626
634
  caption: textContent.trim() || undefined,
635
+ ...(textContent.trim() ? htmlMode : {}),
627
636
  ...replyParams(),
628
637
  },
629
638
  });
@@ -640,6 +649,7 @@ function buildOutboundActions(
640
649
  chat_id: chatId,
641
650
  video,
642
651
  caption: textContent.trim() || undefined,
652
+ ...(textContent.trim() ? htmlMode : {}),
643
653
  ...replyParams(),
644
654
  },
645
655
  });
@@ -656,6 +666,7 @@ function buildOutboundActions(
656
666
  chat_id: chatId,
657
667
  audio,
658
668
  caption: textContent.trim() || undefined,
669
+ ...(textContent.trim() ? htmlMode : {}),
659
670
  ...replyParams(),
660
671
  },
661
672
  });
@@ -672,6 +683,7 @@ function buildOutboundActions(
672
683
  chat_id: chatId,
673
684
  voice,
674
685
  caption: textContent.trim() || undefined,
686
+ ...(textContent.trim() ? htmlMode : {}),
675
687
  ...replyParams(),
676
688
  },
677
689
  });
@@ -688,6 +700,7 @@ function buildOutboundActions(
688
700
  chat_id: chatId,
689
701
  document,
690
702
  caption: textContent.trim() || undefined,
703
+ ...(textContent.trim() ? htmlMode : {}),
691
704
  ...replyParams(),
692
705
  },
693
706
  });
@@ -718,7 +731,7 @@ function buildOutboundActions(
718
731
  break;
719
732
  }
720
733
  default:
721
- textContent += String(data.text ?? `[${item.type}]`);
734
+ textContent += appendPlain(data.text ?? `[${item.type}]`);
722
735
  }
723
736
  }
724
737
 
@@ -730,6 +743,7 @@ function buildOutboundActions(
730
743
  params: {
731
744
  chat_id: chatId,
732
745
  text: text || ' ',
746
+ ...htmlMode,
733
747
  ...replyParams(),
734
748
  ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
735
749
  },
@@ -742,6 +756,7 @@ function buildOutboundActions(
742
756
  params: {
743
757
  chat_id: chatId,
744
758
  text: textContent.trim() || ' ',
759
+ ...htmlMode,
745
760
  ...replyParams(),
746
761
  ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
747
762
  },