@zhin.js/adapter-telegram 1.1.19 → 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 +50 -0
- package/README.md +28 -0
- package/lib/adapter.js +1 -1
- package/lib/console-spec.js +2 -0
- package/lib/endpoint.js +11 -0
- package/lib/markdown-to-html.d.ts +5 -7
- package/lib/markdown-to-html.js +90 -51
- package/lib/protocol.d.ts +7 -1
- package/lib/protocol.js +33 -31
- package/lib/self-profile.d.ts +11 -0
- package/lib/self-profile.js +61 -0
- package/lib/stream-markdown.d.ts +6 -0
- package/lib/stream-markdown.js +66 -0
- package/lib/streaming.d.ts +4 -0
- package/lib/streaming.js +118 -0
- package/package.json +22 -21
- package/schema.json +126 -1
- package/src/adapter.ts +1 -1
- package/src/console-spec.ts +2 -0
- package/src/endpoint.ts +10 -0
- package/src/markdown-to-html.ts +104 -64
- package/src/protocol.ts +33 -28
- package/src/self-profile.ts +58 -0
- package/src/stream-markdown.ts +67 -0
- package/src/streaming.ts +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
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
|
+
|
|
46
|
+
## 1.1.20
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- Updated dependencies [5e878ba]
|
|
51
|
+
- @zhin.js/page@1.1.12
|
|
52
|
+
|
|
3
53
|
## 1.1.19
|
|
4
54
|
|
|
5
55
|
### 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); }
|
package/lib/console-spec.js
CHANGED
|
@@ -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;
|
package/lib/markdown-to-html.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
value =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (
|
|
32
|
-
value =
|
|
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+"[^&]*")?\)/g, (_match, group) => group(1));
|
|
25
|
+
value = replace(value, /\[([^\[\]]+)\]\(([^()\s]+)(?:\s+"[^&]*")?\)/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, /^>\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
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
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 <
|
|
40
|
-
const opening =
|
|
82
|
+
while (cursor < value.text.length) {
|
|
83
|
+
const opening = value.text.indexOf('```', cursor);
|
|
41
84
|
if (opening < 0) {
|
|
42
|
-
chunks.push(
|
|
85
|
+
chunks.push(slice(value, cursor));
|
|
43
86
|
break;
|
|
44
87
|
}
|
|
45
|
-
chunks.push(
|
|
88
|
+
chunks.push(slice(value, cursor, opening));
|
|
46
89
|
const contentStart = opening + 3;
|
|
47
|
-
const closing =
|
|
90
|
+
const closing = value.text.indexOf('```', contentStart);
|
|
48
91
|
if (closing < 0) {
|
|
49
|
-
chunks.push(
|
|
92
|
+
chunks.push(slice(value, opening));
|
|
50
93
|
break;
|
|
51
94
|
}
|
|
52
|
-
const newline =
|
|
95
|
+
const newline = value.text.indexOf('\n', contentStart);
|
|
53
96
|
const hasHeader = newline >= 0 && newline < closing;
|
|
54
|
-
const language =
|
|
55
|
-
let code = hasHeader ?
|
|
56
|
-
if (code.endsWith('\n'))
|
|
57
|
-
code =
|
|
58
|
-
const
|
|
59
|
-
chunks.push(/^[A-Za-z0-9_+-]+$/.test(language)
|
|
60
|
-
?
|
|
61
|
-
:
|
|
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
|
|
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, '&')
|
|
72
|
-
.replace(/</g, '<')
|
|
73
|
-
.replace(/>/g, '>')
|
|
74
|
-
.replace(/"/g, '"');
|
|
113
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
};
|