@zhin.js/adapter-telegram 1.1.20 → 1.1.21

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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.21
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ - 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.
14
+
15
+ 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.
16
+
17
+ 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.
18
+
19
+ - 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.
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ - Updated dependencies [b88e988]
28
+ - Updated dependencies [f78eb84]
29
+ - Updated dependencies [c15cef9]
30
+ - Updated dependencies [b0c02a2]
31
+ - Updated dependencies [af8f803]
32
+ - Updated dependencies [26fda97]
33
+ - Updated dependencies [976886c]
34
+ - Updated dependencies [032dc96]
35
+ - @zhin.js/adapter@1.1.27
36
+ - @zhin.js/client@1.1.23
37
+ - @zhin.js/im-contract@1.1.10
38
+ - @zhin.js/core@1.1.55
39
+ - @zhin.js/tool@1.1.14
40
+ - @zhin.js/page@1.1.13
41
+ - @zhin.js/host-http@1.1.13
42
+ - @zhin.js/agent-feature@1.1.12
43
+ - @zhin.js/feature-kit@1.1.11
44
+ - @zhin.js/skill@1.1.12
45
+
3
46
  ## 1.1.20
4
47
 
5
48
  ### 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)。
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 {};
@@ -0,0 +1,61 @@
1
+ import { TelegramApiError } from './api-error.js';
2
+ /** Bot API names/descriptions are account fields, not the title/description of a chat. */
3
+ export function createTelegramSelfProfile({ client, current, nicknameChanged }) {
4
+ return Object.freeze({
5
+ writableFields: Object.freeze(['nickname', 'signature', 'bio', 'avatar']),
6
+ get current() { return current(); },
7
+ async read({ signal }) {
8
+ const [name, description, shortDescription] = await Promise.all([
9
+ client.callApi('getMyName', {}, signal),
10
+ client.callApi('getMyDescription', {}, signal),
11
+ client.callApi('getMyShortDescription', {}, signal),
12
+ ]);
13
+ if (typeof name.name !== 'string' || typeof description.description !== 'string' || typeof shortDescription.short_description !== 'string') {
14
+ throw new Error('Telegram returned an incomplete bot profile');
15
+ }
16
+ return { nickname: name.name, signature: shortDescription.short_description, bio: description.description };
17
+ },
18
+ async write({ change, signal }) {
19
+ try {
20
+ if (change.field === 'avatar') {
21
+ if (change.value.kind !== 'base64' || change.value.mime_type !== 'image/jpeg') {
22
+ return { status: 'rejected', message: 'Telegram profile photos require resolved JPEG image bytes' };
23
+ }
24
+ const form = new FormData();
25
+ form.set('photo', JSON.stringify({ type: 'static', photo: 'attach://avatar' }));
26
+ form.set('avatar', new Blob([Uint8Array.from(Buffer.from(change.value.value, 'base64'))], { type: 'image/jpeg' }), 'avatar.jpg');
27
+ const accepted = await client.callApiForm('setMyProfilePhoto', form, signal);
28
+ return accepted === true ? { status: 'updated' } : { status: 'rejected', message: 'Telegram rejected the avatar change' };
29
+ }
30
+ let method;
31
+ let params;
32
+ switch (change.field) {
33
+ case 'nickname':
34
+ method = 'setMyName';
35
+ params = { name: change.value };
36
+ break;
37
+ case 'signature':
38
+ method = 'setMyShortDescription';
39
+ params = { short_description: change.value };
40
+ break;
41
+ case 'bio':
42
+ method = 'setMyDescription';
43
+ params = { description: change.value };
44
+ break;
45
+ default: return { status: 'rejected', message: `Telegram bot profile does not expose ${change.field} through this port` };
46
+ }
47
+ const accepted = await client.callApi(method, params, signal);
48
+ if (accepted !== true)
49
+ return { status: 'rejected', message: `Telegram rejected the ${change.field} change` };
50
+ if (change.field === 'nickname')
51
+ nicknameChanged(change.value);
52
+ return { status: 'updated', profile: { [change.field]: change.value } };
53
+ }
54
+ catch (error) {
55
+ if (!(error instanceof TelegramApiError) || error.disposition === 'not_sent')
56
+ throw error;
57
+ return { status: error.disposition === 'rejected' ? 'rejected' : 'unknown', message: error.message };
58
+ }
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,6 @@
1
+ /** Render a physical slice while retaining the original Markdown's source coordinates. */
2
+ export declare function sliceTelegramMarkdown(text: string, precedingText: string, capacity: number): {
3
+ readonly html: string;
4
+ readonly acceptedText: string;
5
+ readonly exhausted: boolean;
6
+ };