@zhin.js/adapter-telegram 1.1.20 → 1.1.22

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,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 41ff1a5: ADR 0044 C5: every official adapter now declares a capability `profile` (its `segments` moved into `profile.default.messaging.segments`). icqq declares the complete profile (per conversation kind: scene, all 14 actions with real limits, events, feedback, delivery) and implements the seven new management ports (`pokeUser`, `unmuteGroupMember`, `muteAllGroupMembers`, `setGroupCard`, `setGroupTitle`, `setGroupEssence`, `inviteGroupMember`) with fresh bot-role reads. qq official declares its passive reply windows; undeclared capabilities stay unsupported.
8
+ - cbd3f14: ADR 0044 integration and C6 guard. `delivery.spool` now reproduces the pre-0044 media rule (icqq `false`, every other official adapter `true`). icqq narrows `actions.recall.others` unless the Bot is admin or owner, and declares recall for groups. Endpoint kind capabilities merge the Endpoint's `operations` (Discord reactions keep working; Discord declares reaction feedback). Adapters in the `@zhin.js/` scope are trusted: their custom prompt text is not labelled `external_content`. Core no longer keeps a telegram/discord interactive-policy table (adapters declare `segments.interactive`). New `pnpm check:no-platform-branches` blocks platform-name branches in runtime code.
9
+ - 4fe49b5: 支持配置按需生效:分离保存与有效版本,独立变更先应用,账号资料更新保留连接,模型按回合使用确定版本;Console 返回结构化应用结果。监听改为仅显式 --watch 启用,开发模板显式选择源码和配置监听。
10
+
11
+ 自变更消费配置应用回执,避免已处理配置被二次重载;持久化本次写入修订和应用结果,未知结果或修订冲突时停止自动续作并保留现场,恢复不会覆盖未经本次变更确认的配置。
12
+
13
+ - Updated dependencies [b249e5e]
14
+ - Updated dependencies [57661f6]
15
+ - Updated dependencies [cbd3f14]
16
+ - Updated dependencies [299863a]
17
+ - Updated dependencies [aaa93d7]
18
+ - Updated dependencies [bd524d1]
19
+ - Updated dependencies [126ae99]
20
+ - Updated dependencies [4fe49b5]
21
+ - Updated dependencies [cce07db]
22
+ - Updated dependencies [b652db3]
23
+ - Updated dependencies [0315ba0]
24
+ - Updated dependencies [00be764]
25
+ - @zhin.js/core@1.1.56
26
+ - @zhin.js/adapter@1.1.28
27
+ - @zhin.js/im-contract@1.1.11
28
+ - @zhin.js/tool@1.1.15
29
+ - @zhin.js/skill@1.1.13
30
+ - @zhin.js/host-http@1.1.14
31
+ - @zhin.js/feature-kit@1.1.12
32
+ - @zhin.js/client@1.1.24
33
+ - @zhin.js/page@1.1.14
34
+ - @zhin.js/agent-feature@1.1.13
35
+
36
+ ## 1.1.21
37
+
38
+ ### Patch Changes
39
+
40
+ - af8f803: Use the current Endpoint's platform profile for Agent account identity, with per-field configured fallbacks and scoped, identity-approved profile tools. Keep SOUL first-person and independent of account names. Profile ports preserve the source Client, cancellation, truthful update outcomes and private image authority.
41
+
42
+ Adapter Feature API 2.1 and Tool Feature API 1.1 describe the additive profile requirements; new Core, official adapters and generated plugins declare the corresponding floors. Existing compatible API-2.0/API-1.0 plugins remain accepted by the newer providers. npm releases remain patch.
43
+
44
+ Private IM assets now bind to the full canonical conversation and principal. Existing IM asset records that contain only the earlier lossy hash remain on disk until their original expiry, but cannot be safely rebound; recapture them. No automatic file deletion, platform profile mutation or workspace SOUL rewrite occurs during upgrade.
45
+
46
+ - 26fda97: Deliver validated final answers progressively through configured QQ C2C, Telegram drafts, Slack streams, Lark cards and Sandbox updates. Keep complete-message delivery for unsupported scenes, disabled adapters and outbound middleware that does not explicitly support revisions. Discord retains complete-message delivery.
47
+
48
+ Preserve source-account leases, media order and confirmed delivery facts across cancellation, capacity rollover and follow-up answer phases. Preview drafts and uncertain outcomes do not become sent history. Bound each reply's semantic snapshot to 1 MiB, retain confirmed prefixes on failure and avoid replaying published answers.
49
+
50
+ Project the latest confirmed message revision into paginated conversation history and synchronize Sandbox message updates across connections. Existing adapter implementations and complete-send callers remain compatible.
51
+
52
+ - 976886c: Preserve confirmed progressive reply content, routing and delivery evidence across cancellation, rollover and closing failures without replaying published text. Keep error dispositions and actual message references, and abort the owning provider request when its functional text consumer fails.
53
+
54
+ Enable native Sandbox Markdown through the real Core projection so Console updates retain formatting in one message. Preserve instance-level streaming opt-outs, recall tombstones and multi-page synchronization. The first safe content is immediate; later updates retain the configured pacing, including large valid intervals.
55
+
56
+ Keep Slack and Lark Markdown streaming within the current physical message. When continuation formatting cannot be verified, stop and retain confirmed content instead of replaying or silently changing it to plain text. Plain-text continuations retain their original reply route.
57
+
58
+ Use rebuildable revision metadata and indexes in the existing conversation event table for bounded SQL history reads. Original event JSON and retention remain unchanged; older writers and models keep their compatible read path. Supported adapter streaming remains enabled by default, with complete-message fallback for disabled settings or outbound middleware without incremental: true.
59
+
60
+ - Updated dependencies [b88e988]
61
+ - Updated dependencies [f78eb84]
62
+ - Updated dependencies [c15cef9]
63
+ - Updated dependencies [b0c02a2]
64
+ - Updated dependencies [af8f803]
65
+ - Updated dependencies [26fda97]
66
+ - Updated dependencies [976886c]
67
+ - Updated dependencies [032dc96]
68
+ - @zhin.js/adapter@1.1.27
69
+ - @zhin.js/client@1.1.23
70
+ - @zhin.js/im-contract@1.1.10
71
+ - @zhin.js/core@1.1.55
72
+ - @zhin.js/tool@1.1.14
73
+ - @zhin.js/page@1.1.13
74
+ - @zhin.js/host-http@1.1.13
75
+ - @zhin.js/agent-feature@1.1.12
76
+ - @zhin.js/feature-kit@1.1.11
77
+ - @zhin.js/skill@1.1.12
78
+
3
79
  ## 1.1.20
4
80
 
5
81
  ### Patch Changes
package/README.md CHANGED
@@ -57,6 +57,26 @@ pnpm add @zhin.js/adapter-telegram
57
57
 
58
58
  必填字段(`endpoints[i]`):`id`、`token`。
59
59
 
60
+ ## 渐进式回复
61
+
62
+ 私聊默认通过 Telegram 草稿展示生成中的文本或 Markdown,完成后正式发送。草稿是临时预览,不代表消息已保存;取消只停止更新,不会自动把部分草稿正式发送。群聊和不支持的目标继续使用完整消息发送。
63
+
64
+ 首段立即展示,后续更新默认至少间隔 500 毫秒。可在实例配置或单个 endpoint 中设置:
65
+
66
+ ```yaml
67
+ telegram:
68
+ streaming: true
69
+ streamingIntervalMs: 500
70
+ endpoints:
71
+ - id: my-telegram-bot
72
+ token: ${TELEGRAM_TOKEN}
73
+ - id: complete-replies
74
+ token: ${OTHER_TELEGRAM_TOKEN}
75
+ streaming: false
76
+ ```
77
+
78
+ `streaming: false` 关闭生成中的公开更新。草稿与正式回复保持同一话题,最终消息保留引用与交互内容。超过平台单条容量时按内容顺序接续消息,Markdown 代码块和链接保留格式与原文;图片、语音和文件仍使用现有上传发送链路。结果未知时不会自动重复正式发送。
79
+
60
80
  ## 最小配置
61
81
 
62
82
  ```yaml
@@ -172,3 +192,11 @@ MIT
172
192
  ### 禁用按钮
173
193
 
174
194
  canonical keyboard 的 `disabled: true` 按钮映射为 Telegram Bot API 10.3 的 `disabled: {}`,不携带 `callback_data`;启用按钮仍使用原回调数据,并保留 1–64 UTF-8 字节校验。自建 Bot API Server 需支持该版本,见 [DisabledButton](https://core.telegram.org/bots/api#disabledbutton)。
195
+
196
+ ## 当前账号资料
197
+
198
+ 资料端口使用 Bot API 的账号方法,与聊天管理方法分开:`getMyName` / `setMyName` 读取及修改昵称,`getMyShortDescription` / `setMyShortDescription` 处理简短签名(`signature`),`getMyDescription` / `setMyDescription` 处理个人简介(`bio`)。这些操作针对默认语言版本;修改沿用 Agent 现有的身份类审批。
199
+
200
+ `setMyProfilePhoto` 通过 multipart 附件上传已解析的 JPEG 字节,不把 URL 冒充上传文件,也不向模型暴露含 Bot Token 的 Telegram 下载地址。资料端口不返回需凭据访问的文件接口 URL,亦不声明 Bot 的年龄、性别、生日及兴趣可写。
201
+
202
+ `nickname` / `profile` 配置只补足缺失字段;Endpoint 字段优先于适配器默认值,不会修改 Telegram 账号。接口依据 [Bot API 官方文档](https://core.telegram.org/bots/api#getmyname)及[头像设置方法](https://core.telegram.org/bots/api#setmyprofilephoto)。
@@ -8,13 +8,28 @@ import {} from "../../lib/protocol.js";
8
8
  export { TelegramEndpoint } from "../../lib/endpoint.js";
9
9
  export default defineAdapter({
10
10
  capabilities: ['inbound', 'outbound'],
11
- operations: ['recall'],
12
11
  // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
13
12
  // inline keyboard 原生按钮承载交互段。
14
- segments: {
15
- outboundMedia: ['url', 'upload'],
16
- interactive: 'native',
17
- markdown: 'native',
13
+ profile: {
14
+ default: {
15
+ messaging: {
16
+ segments: {
17
+ outboundMedia: ['url', 'upload'],
18
+ interactive: 'native',
19
+ markdown: 'native',
20
+ },
21
+ quote: true,
22
+ recall: { timeLimitSeconds: 172800 },
23
+ },
24
+ actions: {
25
+ recall: {
26
+ scopes: ['private', 'group', 'channel'],
27
+ params: { timeLimitSeconds: 172800 },
28
+ },
29
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
30
+ },
31
+ delivery: { send: true, proactive: true, spool: true },
32
+ },
18
33
  },
19
34
  createAdapter: context => new TelegramAdapter(context),
20
35
  });
@@ -12,13 +12,28 @@ export type { TelegramEndpointOptions, TelegramFetch } from '../../src/endpoint.
12
12
 
13
13
  export default defineAdapter<TelegramEndpointConfig>({
14
14
  capabilities: ['inbound', 'outbound'],
15
- operations: ['recall'],
16
15
  // 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
17
16
  // inline keyboard 原生按钮承载交互段。
18
- segments: {
19
- outboundMedia: ['url', 'upload'],
20
- interactive: 'native',
21
- markdown: 'native',
17
+ profile: {
18
+ default: {
19
+ messaging: {
20
+ segments: {
21
+ outboundMedia: ['url', 'upload'],
22
+ interactive: 'native',
23
+ markdown: 'native',
24
+ },
25
+ quote: true,
26
+ recall: { timeLimitSeconds: 172800 },
27
+ },
28
+ actions: {
29
+ recall: {
30
+ scopes: ['private', 'group', 'channel'],
31
+ params: { timeLimitSeconds: 172800 },
32
+ },
33
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
34
+ },
35
+ delivery: { send: true, proactive: true, spool: true },
36
+ },
22
37
  },
23
38
  createAdapter: context => new TelegramAdapter(context),
24
39
  });
package/lib/adapter.js CHANGED
@@ -14,7 +14,7 @@ export class TelegramAdapter extends Adapter {
14
14
  get configurationIdentityFields() { return ['id']; }
15
15
  get configurationIdentityProbeFields() { return ['token', 'apiBaseUrl']; }
16
16
  get configurationUpdateFields() {
17
- return ['token', 'polling', 'webhook',
17
+ return ['token', 'polling', 'streaming', 'streamingIntervalMs', 'webhook',
18
18
  'webhook.domain', 'webhook.path', 'webhook.secretToken', 'allowedUpdates', 'apiBaseUrl'];
19
19
  }
20
20
  validateConfiguration(config) { resolveTelegramConfig(config); }
@@ -5,6 +5,8 @@ export const telegramConsoleSpec = {
5
5
  { key: 'token', required: true, env: true, description: 'Telegram bot token' },
6
6
  { key: 'apiBaseUrl', description: 'Telegram Bot API URL' },
7
7
  { key: 'polling', type: 'boolean', description: 'Use long polling; false selects webhook mode' },
8
+ { key: 'streaming', type: 'boolean', description: 'Show private-chat drafts while generating (default true)' },
9
+ { key: 'streamingIntervalMs', type: 'number', description: 'Delay between draft updates in milliseconds (default 500)' },
8
10
  { key: 'webhook.domain', description: 'Public webhook origin' },
9
11
  { key: 'webhook.path', description: 'Local webhook route' },
10
12
  { key: 'webhook.secretToken', env: true, description: 'Webhook secret token' },
package/lib/endpoint.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TelegramClient } from './telegram-client.js';
2
+ import { createTelegramStreaming } from './streaming.js';
2
3
  import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, } from 'zhin.js/adapter';
3
4
  /**
4
5
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
@@ -11,6 +12,7 @@ export { TelegramApiError } from './api-error.js';
11
12
  import { normalizeTelegramChatMember } from './platform-permit.js';
12
13
  import { buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveTelegramChannelType, senderDisplayName, telegramInboundConversation, } from './protocol.js';
13
14
  import { registerTelegramWebhookRoutes } from './webhook.js';
15
+ import { createTelegramSelfProfile } from './self-profile.js';
14
16
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
15
17
  const CHAT_MEMBER_CACHE_MAX = 2_000;
16
18
  const ADMITTED_UPDATE_LIMIT = 2_048;
@@ -37,6 +39,7 @@ export class TelegramEndpoint extends TelegramClient {
37
39
  #inflightUpdates = new Map();
38
40
  #botUserId;
39
41
  #botUsername;
42
+ #botName;
40
43
  #chatMemberCache = new Map();
41
44
  #control = Object.freeze({
42
45
  recall: ({ message, signal }) => this.recallMessage(message, signal),
@@ -55,6 +58,11 @@ export class TelegramEndpoint extends TelegramClient {
55
58
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
56
59
  get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
57
60
  control: this.#control, content: this.#content,
61
+ streaming: options.config.streaming !== false ? createTelegramStreaming(() => this, options.config.streamingIntervalMs) : undefined,
62
+ profile: createTelegramSelfProfile({ client: this,
63
+ current: () => this.#botName === undefined ? {} : { nickname: this.#botName },
64
+ nicknameChanged: nickname => { this.#botName = nickname; },
65
+ }),
58
66
  });
59
67
  }
60
68
  /** Used by webhook handler. */
@@ -105,6 +113,9 @@ export class TelegramEndpoint extends TelegramClient {
105
113
  return;
106
114
  this.#botUserId = me.id;
107
115
  this.#botUsername = me.username;
116
+ this.#botName = me.first_name;
117
+ if (me.id)
118
+ this[endpointSpec].reportSelfUserId(String(me.id));
108
119
  if (this.#options.config.mode === 'webhook') {
109
120
  if (!this.#options.http) {
110
121
  throw new TypeError('Telegram webhook mode requires httpHostToken');
@@ -1,9 +1,7 @@
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
1
  export declare function markdownToTelegramHtml(markdown: string): string;
2
+ /** Shares exactly the complete-message codec; generated tags never consume source text. */
3
+ export declare function projectTelegramMarkdown(markdown: string): {
4
+ readonly html: string;
5
+ readonly source: readonly number[];
6
+ };
9
7
  export declare function escapeTelegramHtml(value: string): string;
@@ -1,75 +1,114 @@
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
1
  export function markdownToTelegramHtml(markdown) {
9
- const protectedFragments = [];
10
- const protect = (html) => {
11
- const token = `\uE000${protectedFragments.length}\uE001`;
12
- protectedFragments.push(html);
2
+ return projectMarkdown({ text: markdown }).text;
3
+ }
4
+ /** Shares exactly the complete-message codec; generated tags never consume source text. */
5
+ export function projectTelegramMarkdown(markdown) {
6
+ const value = projectMarkdown({ text: markdown, source: Array.from({ length: markdown.length }, (_, index) => index) });
7
+ return { html: value.text, source: value.source };
8
+ }
9
+ function projectMarkdown(markdown) {
10
+ const fragments = [];
11
+ const literal = (text, origin = -1) => ({ text,
12
+ ...(markdown.source ? { source: Array(text.length).fill(origin) } : {}) });
13
+ const protect = (value) => {
14
+ const token = literal(`\uE000${fragments.length}\uE001`);
15
+ fragments.push(value);
13
16
  return token;
14
17
  };
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]);
18
+ const wrapped = (tag, value) => join([
19
+ literal(`<${tag}>`), value, literal(`</${tag.split(' ')[0]}>`),
20
+ ]);
21
+ let value = fencedCode(markdown, code => protect(code), literal);
22
+ value = replace(value, /`([^`\n]+)`/g, (_match, group) => protect(wrapped('code', escape(group(1)))));
23
+ value = escape(value);
24
+ value = replace(value, /!\[([^\[\]]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, group) => group(1));
25
+ value = replace(value, /\[([^\[\]]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, group) => {
26
+ const label = group(1), href = group(2);
27
+ return isSafeTelegramHref(href.text) ? join([literal('<a href="'), href, literal('">'), label, literal('</a>')]) : label;
28
+ });
29
+ value = replace(value, /^#{1,6}\s+(.+)$/gm, (_match, group) => wrapped('b', group(1)));
30
+ value = replace(value, /^&gt;\s?(.*)$/gm, (_match, group) => wrapped('blockquote', group(1)));
31
+ value = replace(value, /^\s*[-+*]\s+(.+)$/gm, (_match, group) => join([
32
+ literal('• ', group(0).source?.find(position => position >= 0)), group(1),
33
+ ]));
34
+ for (const [pattern, tag] of [[/\*\*([^*\n]+)\*\*/g, 'b'], [/__([^_\n]+)__/g, 'b'], [/~~([^~\n]+)~~/g, 's']]) {
35
+ value = replace(value, pattern, (_match, group) => wrapped(tag, group(1)));
36
+ }
37
+ for (const pattern of [/(^|[^*])\*([^*\n]+)\*/g, /(^|[^_])_([^_\n]+)_/g]) {
38
+ value = replace(value, pattern, (_match, group) => join([group(1), wrapped('i', group(2))]));
39
+ }
40
+ for (let index = 0; index < fragments.length; index++) {
41
+ value = replace(value, new RegExp(`\uE000${index}\uE001`), () => fragments[index]);
33
42
  }
34
43
  return value;
35
44
  }
36
- function protectTelegramFencedCode(markdown, protect) {
45
+ function slice(value, start, end) {
46
+ return { text: value.text.slice(start, end), ...(value.source ? { source: value.source.slice(start, end) } : {}) };
47
+ }
48
+ function join(values) {
49
+ const mapped = values.some(value => value.source);
50
+ return { text: values.map(value => value.text).join(''),
51
+ ...(mapped ? { source: values.flatMap(value => value.source ? [...value.source] : Array(value.text.length).fill(-1)) } : {}) };
52
+ }
53
+ function replace(value, pattern, project) {
54
+ const matcher = new RegExp(pattern.source, `${pattern.flags}d`);
55
+ const chunks = [];
56
+ let cursor = 0;
57
+ for (let match = matcher.exec(value.text); match; match = matcher.global ? matcher.exec(value.text) : null) {
58
+ chunks.push(slice(value, cursor, match.index));
59
+ const matched = match;
60
+ chunks.push(project(match, index => {
61
+ const [start, end] = matched.indices[index];
62
+ return slice(value, start, end);
63
+ }));
64
+ cursor = match.index + match[0].length;
65
+ if (!matcher.global)
66
+ break;
67
+ }
68
+ chunks.push(slice(value, cursor));
69
+ return join(chunks);
70
+ }
71
+ function escape(value) {
72
+ const entities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
73
+ return replace(value, /[&<>"]/g, (match, group) => {
74
+ const origin = group(0).source?.[0];
75
+ const text = entities[match[0]];
76
+ return { text, ...(origin !== undefined ? { source: Array(text.length).fill(origin) } : {}) };
77
+ });
78
+ }
79
+ function fencedCode(value, protect, literal) {
37
80
  const chunks = [];
38
81
  let cursor = 0;
39
- while (cursor < markdown.length) {
40
- const opening = markdown.indexOf('```', cursor);
82
+ while (cursor < value.text.length) {
83
+ const opening = value.text.indexOf('```', cursor);
41
84
  if (opening < 0) {
42
- chunks.push(markdown.slice(cursor));
85
+ chunks.push(slice(value, cursor));
43
86
  break;
44
87
  }
45
- chunks.push(markdown.slice(cursor, opening));
88
+ chunks.push(slice(value, cursor, opening));
46
89
  const contentStart = opening + 3;
47
- const closing = markdown.indexOf('```', contentStart);
90
+ const closing = value.text.indexOf('```', contentStart);
48
91
  if (closing < 0) {
49
- chunks.push(markdown.slice(opening));
92
+ chunks.push(slice(value, opening));
50
93
  break;
51
94
  }
52
- const newline = markdown.indexOf('\n', contentStart);
95
+ const newline = value.text.indexOf('\n', contentStart);
53
96
  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>`));
97
+ const language = value.text.slice(contentStart, hasHeader ? newline : closing).trim();
98
+ let code = hasHeader ? slice(value, newline + 1, closing) : literal('');
99
+ if (code.text.endsWith('\n'))
100
+ code = slice(code, 0, -1);
101
+ const escaped = escape(code);
102
+ chunks.push(protect(/^[A-Za-z0-9_+-]+$/.test(language)
103
+ ? join([literal(`<pre><code class="language-${language}">`), escaped, literal('</code></pre>')])
104
+ : join([literal('<pre>'), escaped, literal('</pre>')])));
62
105
  cursor = closing + 3;
63
106
  }
64
- return chunks.join('');
107
+ return join(chunks);
65
108
  }
66
109
  function isSafeTelegramHref(value) {
67
110
  return /^(?:https?:\/\/|tg:\/\/|mailto:)[^\s<>]+$/iu.test(value);
68
111
  }
69
112
  export function escapeTelegramHtml(value) {
70
- return value
71
- .replace(/&/g, '&amp;')
72
- .replace(/</g, '&lt;')
73
- .replace(/>/g, '&gt;')
74
- .replace(/"/g, '&quot;');
113
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
75
114
  }
package/lib/protocol.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
1
2
  /**
2
3
  * Telegram Bot API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
4
  * Canonicalization is owned by gateway/core before endpoint.send.
@@ -6,11 +7,14 @@ import type { IncomingMessage } from 'node:http';
6
7
  import { type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
7
8
  import type { Segment } from '@zhin.js/core/runtime';
8
9
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
9
- export interface TelegramEndpointConfig {
10
+ export interface TelegramEndpointConfig extends EndpointProfileConfiguration {
10
11
  readonly id: string;
11
12
  readonly token: string;
12
13
  /** Default true. `false` selects webhook mode (requires httpHostToken). */
13
14
  readonly polling?: boolean;
15
+ /** Show private-chat drafts while generating; false keeps complete delivery. */
16
+ readonly streaming?: boolean;
17
+ readonly streamingIntervalMs?: number;
14
18
  readonly webhook?: {
15
19
  readonly domain?: string;
16
20
  readonly path?: string;
@@ -26,6 +30,8 @@ export interface ResolvedTelegramConfig {
26
30
  readonly mode: 'polling' | 'webhook';
27
31
  readonly allowedUpdates: readonly string[];
28
32
  readonly apiBaseUrl: string;
33
+ readonly streaming?: boolean;
34
+ readonly streamingIntervalMs?: number;
29
35
  readonly webhook?: {
30
36
  readonly domain: string;
31
37
  readonly path: string;
package/lib/protocol.js CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Telegram Bot API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
- * Canonicalization is owned by gateway/core before endpoint.send.
4
- */
5
1
  import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
6
2
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
3
  import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
@@ -9,6 +5,9 @@ const logger = getLogger('telegram');
9
5
  export function resolveTelegramConfig(config) {
10
6
  const id = requiredEndpointField(config.id, 'id');
11
7
  const token = requiredEndpointField(config.token, 'token');
8
+ const streamingIntervalMs = config.streamingIntervalMs ?? 500;
9
+ if (!Number.isFinite(streamingIntervalMs) || streamingIntervalMs < 0)
10
+ throw new TypeError('streamingIntervalMs must be a non-negative finite number');
12
11
  const polling = config.polling;
13
12
  const webhookSource = config.webhook;
14
13
  const mode = polling === false ? 'webhook' : 'polling';
@@ -28,6 +27,8 @@ export function resolveTelegramConfig(config) {
28
27
  mode,
29
28
  allowedUpdates: [...allowedUpdates],
30
29
  apiBaseUrl,
30
+ streaming: config.streaming !== false,
31
+ streamingIntervalMs,
31
32
  webhook,
32
33
  };
33
34
  }
@@ -239,6 +240,19 @@ function buildOutboundActions(target, payload, uploads) {
239
240
  let keyboard;
240
241
  const actions = [];
241
242
  const replyParams = () => (replyTo != null ? { reply_parameters: { message_id: replyTo } } : {});
243
+ const flushText = () => {
244
+ if (!textContent.trim() && !keyboard)
245
+ return;
246
+ actions.push({ method: 'sendMessage', params: {
247
+ chat_id: chatId,
248
+ text: textContent.trim() || ' ',
249
+ ...htmlMode,
250
+ ...replyParams(),
251
+ ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
252
+ } });
253
+ textContent = '';
254
+ keyboard = undefined;
255
+ };
242
256
  /**
243
257
  * 媒体来源归一:唯一来源是 canonical `data.media` MediaRef。
244
258
  * kind=url/file → 字符串直发(file 即 Telegram file_id 不透明引用);
@@ -352,6 +366,8 @@ function buildOutboundActions(target, payload, uploads) {
352
366
  break;
353
367
  }
354
368
  case 'image': {
369
+ if (keyboard)
370
+ flushText();
355
371
  const photo = mediaSource('image', data, 'image.png');
356
372
  if (photo) {
357
373
  actions.push({
@@ -369,6 +385,8 @@ function buildOutboundActions(target, payload, uploads) {
369
385
  break;
370
386
  }
371
387
  case 'video': {
388
+ if (keyboard)
389
+ flushText();
372
390
  const video = mediaSource('video', data, 'video.mp4');
373
391
  if (video) {
374
392
  actions.push({
@@ -386,6 +404,8 @@ function buildOutboundActions(target, payload, uploads) {
386
404
  break;
387
405
  }
388
406
  case 'audio': {
407
+ if (keyboard)
408
+ flushText();
389
409
  const audio = mediaSource('audio', data, 'audio.mp3');
390
410
  if (audio) {
391
411
  actions.push({
@@ -403,6 +423,8 @@ function buildOutboundActions(target, payload, uploads) {
403
423
  break;
404
424
  }
405
425
  case 'voice': {
426
+ if (keyboard)
427
+ flushText();
406
428
  const voice = mediaSource('voice', data, 'voice.ogg');
407
429
  if (voice) {
408
430
  actions.push({
@@ -420,6 +442,8 @@ function buildOutboundActions(target, payload, uploads) {
420
442
  break;
421
443
  }
422
444
  case 'file': {
445
+ if (keyboard)
446
+ flushText();
423
447
  const document = mediaSource('file', data, 'file');
424
448
  if (document) {
425
449
  actions.push({
@@ -439,6 +463,7 @@ function buildOutboundActions(target, payload, uploads) {
439
463
  case 'sticker': {
440
464
  const sticker = mediaSource('sticker', data, 'sticker.webp');
441
465
  if (sticker) {
466
+ flushText();
442
467
  actions.push({
443
468
  method: 'sendSticker',
444
469
  params: { chat_id: chatId, sticker, ...replyParams() },
@@ -447,6 +472,7 @@ function buildOutboundActions(target, payload, uploads) {
447
472
  break;
448
473
  }
449
474
  case 'location': {
475
+ flushText();
450
476
  actions.push({
451
477
  method: 'sendLocation',
452
478
  params: {
@@ -462,32 +488,8 @@ function buildOutboundActions(target, payload, uploads) {
462
488
  textContent += appendPlain(data.text ?? `[${item.type}]`);
463
489
  }
464
490
  }
465
- if (actions.length === 0) {
466
- const text = textContent.trim() || (keyboard ? ' ' : '');
467
- if (!text && !keyboard)
468
- throw new Error('No Telegram content to send');
469
- return [{
470
- method: 'sendMessage',
471
- params: {
472
- chat_id: chatId,
473
- text: text || ' ',
474
- ...htmlMode,
475
- ...replyParams(),
476
- ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
477
- },
478
- }];
479
- }
480
- if (textContent.trim() || keyboard) {
481
- actions.unshift({
482
- method: 'sendMessage',
483
- params: {
484
- chat_id: chatId,
485
- text: textContent.trim() || ' ',
486
- ...htmlMode,
487
- ...replyParams(),
488
- ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
489
- },
490
- });
491
- }
491
+ flushText();
492
+ if (actions.length === 0)
493
+ throw new Error('No Telegram content to send');
492
494
  return actions;
493
495
  }
@@ -0,0 +1,11 @@
1
+ import type { EndpointProfile } from 'zhin.js/adapter';
2
+ import type { SelfProfile } from '@zhin.js/im-contract';
3
+ import type { TelegramClientApi } from './telegram-client.js';
4
+ interface TelegramSelfProfileOptions {
5
+ readonly client: Pick<TelegramClientApi, 'callApi' | 'callApiForm'>;
6
+ readonly current: () => Readonly<SelfProfile>;
7
+ readonly nicknameChanged: (nickname: string) => void;
8
+ }
9
+ /** Bot API names/descriptions are account fields, not the title/description of a chat. */
10
+ export declare function createTelegramSelfProfile({ client, current, nicknameChanged }: TelegramSelfProfileOptions): EndpointProfile;
11
+ export {};