@zhin.js/adapter-telegram 6.0.0 → 6.0.1
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 +42 -0
- package/adapters/telegram.ts +12 -0
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.d.ts +8 -4
- package/lib/endpoint.js +67 -4
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/polling.js +2 -2
- package/lib/protocol.d.ts +39 -0
- package/lib/protocol.js +137 -14
- package/lib/telegram-endpoint-commands.d.ts +1 -0
- package/lib/telegram-endpoint-commands.js +16 -0
- package/lib/telegram-runtime-state.d.ts +1 -0
- package/lib/telegram-runtime-state.js +6 -0
- package/lib/webhook.d.ts +2 -3
- package/lib/webhook.js +11 -1
- package/package.json +19 -13
- package/plugin.ts +5 -1
- package/src/endpoint.ts +82 -5
- package/src/index.ts +3 -0
- package/src/polling.ts +2 -1
- package/src/protocol.ts +164 -11
- package/src/telegram-endpoint-commands.ts +17 -0
- package/src/telegram-runtime-state.ts +7 -0
- package/src/webhook.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
8
|
+
|
|
9
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
10
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
11
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
12
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
13
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
14
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
15
|
+
|
|
16
|
+
- 9c997b2: 通用 endpoint 管理命令套件:`@zhin.js/adapter` 新增 `createEndpointCommands(spec, defineCommand)`——`<adapter> endpoint list / add <name> key=value... / remove <name>` 三件套,含 kv 解析、`.env` 凭据派生(`<ADAPTER>_<NAME>_<FIELD>`)、yaml 写回保留注释、master 权限门禁(通用 `isEndpointOperator`)、自定义 bindFlow 钩子。QQ 迁移至套件(行为与扫码绑定流程不变);napcat / onebot11 / onebot12 / milky / slack / telegram 接入(字段对齐各自 schema,features 补 @zhin.js/command)。
|
|
17
|
+
- 6cb6152: 统一消息元素通道(UNI-Channel)落地:
|
|
18
|
+
|
|
19
|
+
- **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
|
|
20
|
+
- **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
|
|
21
|
+
- **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
|
|
22
|
+
- **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [cdf64e7]
|
|
25
|
+
- Updated dependencies [2d0a159]
|
|
26
|
+
- Updated dependencies [5691aba]
|
|
27
|
+
- Updated dependencies [078e3f7]
|
|
28
|
+
- Updated dependencies [50497a5]
|
|
29
|
+
- Updated dependencies [9c997b2]
|
|
30
|
+
- Updated dependencies [09d4f25]
|
|
31
|
+
- Updated dependencies [43485a9]
|
|
32
|
+
- Updated dependencies [f0ec5ab]
|
|
33
|
+
- Updated dependencies [3e925d0]
|
|
34
|
+
- Updated dependencies [fa66c4c]
|
|
35
|
+
- Updated dependencies [fa66c4c]
|
|
36
|
+
- Updated dependencies [6cb6152]
|
|
37
|
+
- @zhin.js/command@1.0.3
|
|
38
|
+
- @zhin.js/agent@1.0.6
|
|
39
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
40
|
+
- @zhin.js/host-http@1.0.3
|
|
41
|
+
- zhin.js@5.0.1
|
|
42
|
+
- @zhin.js/adapter@1.1.1
|
|
43
|
+
- @zhin.js/core@1.4.1
|
|
44
|
+
|
|
3
45
|
## 6.0.0
|
|
4
46
|
|
|
5
47
|
### Patch Changes
|
package/adapters/telegram.ts
CHANGED
|
@@ -9,14 +9,26 @@ import {
|
|
|
9
9
|
resolveTelegramConfig,
|
|
10
10
|
type TelegramAdapterConfig,
|
|
11
11
|
} from '../src/protocol.js';
|
|
12
|
+
import { telegramRuntimeStateToken } from '../src/telegram-runtime-state.js';
|
|
12
13
|
|
|
13
14
|
export { TelegramEndpoint } from '../src/endpoint.js';
|
|
14
15
|
export type { TelegramEndpointOptions, TelegramFetch } from '../src/endpoint.js';
|
|
15
16
|
|
|
16
17
|
export default defineAdapter<TelegramAdapterConfig>({
|
|
17
18
|
capabilities: ['inbound', 'outbound'],
|
|
19
|
+
// 媒体 url / file_id 直发,base64 / 本地路径经 multipart attach:// 上传物化;
|
|
20
|
+
// inline keyboard 原生按钮承载交互段。
|
|
21
|
+
segments: {
|
|
22
|
+
outboundMedia: ['url', 'upload'],
|
|
23
|
+
interactive: 'native',
|
|
24
|
+
},
|
|
18
25
|
create(context) {
|
|
19
26
|
const config = resolveTelegramConfig(context.config);
|
|
27
|
+
// 注册到插件运行时状态(telegram endpoint list 的"运行中"数据源)
|
|
28
|
+
context.use(telegramRuntimeStateToken).endpoints.set(config.name, {
|
|
29
|
+
name: config.name,
|
|
30
|
+
mode: config.mode,
|
|
31
|
+
});
|
|
20
32
|
return new TelegramEndpoint({
|
|
21
33
|
id: context.id,
|
|
22
34
|
gateway: context.use(messageGatewayToken),
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
|
|
3
|
-
*/
|
|
4
1
|
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
5
2
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
3
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
@@ -9,7 +6,7 @@ import { type ResolvedTelegramConfig, type TelegramCallbackQuery, type TelegramC
|
|
|
9
6
|
export type TelegramFetch = (url: string, init?: {
|
|
10
7
|
readonly method?: string;
|
|
11
8
|
readonly headers?: Record<string, string>;
|
|
12
|
-
readonly body?: string;
|
|
9
|
+
readonly body?: string | FormData;
|
|
13
10
|
readonly signal?: AbortSignal;
|
|
14
11
|
}) => Promise<{
|
|
15
12
|
readonly ok: boolean;
|
|
@@ -24,6 +21,11 @@ export interface TelegramEndpointOptions {
|
|
|
24
21
|
readonly http?: HttpHost;
|
|
25
22
|
readonly fetch?: TelegramFetch;
|
|
26
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
|
|
26
|
+
* 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
|
|
27
|
+
* 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
|
|
28
|
+
*/
|
|
27
29
|
export declare class TelegramEndpoint implements EndpointInstance {
|
|
28
30
|
#private;
|
|
29
31
|
constructor(options: TelegramEndpointOptions);
|
|
@@ -48,6 +50,8 @@ export declare class TelegramEndpoint implements EndpointInstance {
|
|
|
48
50
|
/** Used by webhook / polling handlers. */
|
|
49
51
|
handleUpdate(update: TelegramUpdate): void;
|
|
50
52
|
callApi<T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
|
|
53
|
+
/** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
|
|
54
|
+
callApiForm<T = unknown>(method: string, form: FormData, signal?: AbortSignal): Promise<T>;
|
|
51
55
|
pinMessage(chatId: number, messageId: number): Promise<boolean>;
|
|
52
56
|
unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
|
|
53
57
|
setChatDescription(chatId: number, description: string): Promise<boolean>;
|
package/lib/endpoint.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
1
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
6
|
import { runTelegramPollLoop } from './polling.js';
|
|
3
7
|
import { normalizeTelegramChatMember } from './platform-permit.js';
|
|
4
|
-
import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatInboundContent,
|
|
8
|
+
import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveChannel, senderDisplayName, } from './protocol.js';
|
|
5
9
|
import { registerTelegramAgentEndpoint } from './telegram-agent-deps.js';
|
|
6
10
|
import { registerTelegramWebhookRoutes } from './webhook.js';
|
|
7
11
|
const logger = getLogger('telegram');
|
|
8
12
|
const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
|
|
9
13
|
const CHAT_MEMBER_CACHE_MAX = 2_000;
|
|
14
|
+
/**
|
|
15
|
+
* Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
|
|
16
|
+
* 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
|
|
17
|
+
* 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
|
|
18
|
+
*/
|
|
10
19
|
export class TelegramEndpoint {
|
|
11
20
|
#options;
|
|
12
21
|
#fetch;
|
|
@@ -55,6 +64,15 @@ export class TelegramEndpoint {
|
|
|
55
64
|
}
|
|
56
65
|
this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
|
|
57
66
|
const webhook = this.#options.config.webhook;
|
|
67
|
+
if (!webhook.secretToken) {
|
|
68
|
+
// 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
|
|
69
|
+
logger.warn(formatCompact({
|
|
70
|
+
op: 'webhook_no_secret',
|
|
71
|
+
endpoint: this.#options.config.name,
|
|
72
|
+
path: webhook.path,
|
|
73
|
+
hint: 'set webhook.secretToken to authenticate Telegram callbacks',
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
58
76
|
const url = buildWebhookUrl(webhook);
|
|
59
77
|
await this.callApi('setWebhook', {
|
|
60
78
|
url,
|
|
@@ -110,15 +128,45 @@ export class TelegramEndpoint {
|
|
|
110
128
|
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
111
129
|
}
|
|
112
130
|
async send({ target, payload }) {
|
|
113
|
-
const
|
|
131
|
+
const plan = formatOutboundPlan(target, payload);
|
|
114
132
|
let lastId = '';
|
|
115
|
-
for (const action of actions) {
|
|
116
|
-
const
|
|
133
|
+
for (const action of plan.actions) {
|
|
134
|
+
const form = await this.#buildUploadForm(action.params, plan.uploads);
|
|
135
|
+
const result = form
|
|
136
|
+
? await this.callApiForm(action.method, form)
|
|
137
|
+
: await this.callApi(action.method, action.params);
|
|
117
138
|
if (result.message_id != null)
|
|
118
139
|
lastId = String(result.message_id);
|
|
119
140
|
}
|
|
120
141
|
return lastId || `telegram-${Date.now()}`;
|
|
121
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* 含 `attach://` 占位的媒体参数 → multipart/form-data:
|
|
145
|
+
* 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
|
|
146
|
+
* (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
|
|
147
|
+
*/
|
|
148
|
+
async #buildUploadForm(params, uploads) {
|
|
149
|
+
const values = Object.values(params);
|
|
150
|
+
if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://')))
|
|
151
|
+
return undefined;
|
|
152
|
+
const form = new FormData();
|
|
153
|
+
for (const [key, value] of Object.entries(params)) {
|
|
154
|
+
if (value == null)
|
|
155
|
+
continue;
|
|
156
|
+
if (typeof value === 'string' && value.startsWith('attach://')) {
|
|
157
|
+
const upload = uploads.find((item) => `attach://${item.attachName}` === value);
|
|
158
|
+
if (!upload)
|
|
159
|
+
throw new Error(`Telegram upload 未登记: ${value}`);
|
|
160
|
+
const data = upload.source.kind === 'base64'
|
|
161
|
+
? Buffer.from(upload.source.data, 'base64')
|
|
162
|
+
: await readFile(upload.source.path);
|
|
163
|
+
form.append(key, new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined), upload.filename);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
167
|
+
}
|
|
168
|
+
return form;
|
|
169
|
+
}
|
|
122
170
|
/** Test / internal: admit a message when open. */
|
|
123
171
|
admit(msg) {
|
|
124
172
|
if (!this.#open)
|
|
@@ -140,6 +188,7 @@ export class TelegramEndpoint {
|
|
|
140
188
|
adapter: this.#options.id,
|
|
141
189
|
target: channelId,
|
|
142
190
|
content: formatInboundContent(msg),
|
|
191
|
+
segments: formatInboundSegments(msg),
|
|
143
192
|
sender: senderDisplayName(msg.from),
|
|
144
193
|
id: String(msg.message_id),
|
|
145
194
|
metadata: Object.freeze({
|
|
@@ -224,6 +273,7 @@ export class TelegramEndpoint {
|
|
|
224
273
|
adapter: this.#options.id,
|
|
225
274
|
target: channelId,
|
|
226
275
|
content: formatCallbackContent(query),
|
|
276
|
+
segments: formatCallbackSegments(query),
|
|
227
277
|
sender: senderDisplayName(query.from),
|
|
228
278
|
id: query.id,
|
|
229
279
|
metadata: Object.freeze({
|
|
@@ -264,6 +314,19 @@ export class TelegramEndpoint {
|
|
|
264
314
|
body: JSON.stringify(params),
|
|
265
315
|
signal,
|
|
266
316
|
});
|
|
317
|
+
return this.#parseApiResponse(method, response);
|
|
318
|
+
}
|
|
319
|
+
/** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
|
|
320
|
+
async callApiForm(method, form, signal) {
|
|
321
|
+
const url = botApiUrl(this.#options.config, method);
|
|
322
|
+
const response = await this.#fetch(url, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
body: form,
|
|
325
|
+
signal,
|
|
326
|
+
});
|
|
327
|
+
return this.#parseApiResponse(method, response);
|
|
328
|
+
}
|
|
329
|
+
async #parseApiResponse(method, response) {
|
|
267
330
|
const text = await response.text();
|
|
268
331
|
let body;
|
|
269
332
|
try {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { TelegramEndpoint, type TelegramEndpointOptions, type TelegramFetch, } from './endpoint.js';
|
|
2
|
-
export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, type ResolvedTelegramConfig, type TelegramAdapterConfig, type TelegramCallbackQuery, type TelegramChat, type TelegramChatMember, type TelegramMessage, type TelegramOutboundAction, type TelegramUpdate, type TelegramUser, type TelegramWireSegment, } from './protocol.js';
|
|
2
|
+
export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, type ResolvedTelegramConfig, type TelegramAdapterConfig, type TelegramCallbackQuery, type TelegramChat, type TelegramChatMember, type TelegramMessage, type TelegramOutboundAction, type TelegramOutboundPlan, type TelegramOutboundUpload, type TelegramUpdate, type TelegramUser, type TelegramWireSegment, } from './protocol.js';
|
|
3
3
|
export { getTelegramAgentDeps, registerTelegramAgentEndpoint, setTelegramAgentDeps, type TelegramAgentDeps, type TelegramAgentEndpoint, } from './telegram-agent-deps.js';
|
|
4
4
|
export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, registerTelegramPlatformPermitChecker, telegramGroupPermitResolver, } from './platform-permit.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { TelegramEndpoint, } from './endpoint.js';
|
|
2
|
-
export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, } from './protocol.js';
|
|
2
|
+
export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveChannel, resolveTelegramConfig, senderDisplayName, } from './protocol.js';
|
|
3
3
|
export { getTelegramAgentDeps, registerTelegramAgentEndpoint, setTelegramAgentDeps, } from './telegram-agent-deps.js';
|
|
4
4
|
export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, registerTelegramPlatformPermitChecker, telegramGroupPermitResolver, } from './platform-permit.js';
|
package/lib/polling.js
CHANGED
|
@@ -31,9 +31,9 @@ export async function runTelegramPollLoop(host, abortSignal) {
|
|
|
31
31
|
ok: false,
|
|
32
32
|
error: err instanceof Error ? err.message : String(err),
|
|
33
33
|
}));
|
|
34
|
+
// 不在退避后清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS,
|
|
35
|
+
// 保持计数才能让 BACKOFF_DELAY_MS 持续生效(成功时上面才清零)。
|
|
34
36
|
await sleep(consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ? BACKOFF_DELAY_MS : RETRY_DELAY_MS, abortSignal);
|
|
35
|
-
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES)
|
|
36
|
-
consecutiveFailures = 0;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
4
|
*/
|
|
5
5
|
import type { IncomingMessage } from 'node:http';
|
|
6
|
+
import type { Segment } from '@zhin.js/core/runtime';
|
|
6
7
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
7
8
|
export interface TelegramAdapterConfig {
|
|
8
9
|
readonly name?: string;
|
|
@@ -243,8 +244,46 @@ export declare function senderDisplayName(user?: TelegramUser): string;
|
|
|
243
244
|
/** Build inbound text for MessageGateway.receive. */
|
|
244
245
|
export declare function formatInboundContent(msg: TelegramMessage): string;
|
|
245
246
|
export declare function formatCallbackContent(query: TelegramCallbackQuery): string;
|
|
247
|
+
/**
|
|
248
|
+
* 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
|
|
249
|
+
* Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
|
|
250
|
+
* 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
|
|
251
|
+
*/
|
|
252
|
+
export declare function formatInboundSegments(msg: TelegramMessage): Segment[];
|
|
253
|
+
/**
|
|
254
|
+
* callback_query → action 段(Wave 1 C interactive 约定:
|
|
255
|
+
* {type:'action', data:{id, payload, sourceMessageId?}}),
|
|
256
|
+
* 与 formatCallbackContent / metadata.payload 同源。
|
|
257
|
+
*/
|
|
258
|
+
export declare function formatCallbackSegments(query: TelegramCallbackQuery): Segment[];
|
|
259
|
+
/**
|
|
260
|
+
* 出站待上传媒体(base64 / 本地路径 MediaRef 物化为 multipart 附件)。
|
|
261
|
+
* params 里以 `attach://<attachName>` 占位,endpoint 发送时替换为文件 part。
|
|
262
|
+
*/
|
|
263
|
+
export interface TelegramOutboundUpload {
|
|
264
|
+
readonly attachName: string;
|
|
265
|
+
readonly filename: string;
|
|
266
|
+
readonly source: {
|
|
267
|
+
readonly kind: 'base64';
|
|
268
|
+
readonly data: string;
|
|
269
|
+
} | {
|
|
270
|
+
readonly kind: 'path';
|
|
271
|
+
readonly path: string;
|
|
272
|
+
};
|
|
273
|
+
readonly mimeType?: string;
|
|
274
|
+
}
|
|
275
|
+
export interface TelegramOutboundPlan {
|
|
276
|
+
readonly actions: TelegramOutboundAction[];
|
|
277
|
+
readonly uploads: readonly TelegramOutboundUpload[];
|
|
278
|
+
}
|
|
246
279
|
/**
|
|
247
280
|
* Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
|
|
248
281
|
* Segment canonicalization is intentionally not done here.
|
|
249
282
|
*/
|
|
250
283
|
export declare function formatOutboundActions(target: string | number, payload: unknown): TelegramOutboundAction[];
|
|
284
|
+
/**
|
|
285
|
+
* formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
|
|
286
|
+
* 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
|
|
287
|
+
* kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
|
|
288
|
+
*/
|
|
289
|
+
export declare function formatOutboundPlan(target: string | number, payload: unknown): TelegramOutboundPlan;
|
package/lib/protocol.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Telegram Bot API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
3
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
4
|
*/
|
|
5
|
+
import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
|
|
5
6
|
export function resolveTelegramConfig(config = {}) {
|
|
6
7
|
const entry = config.endpoints?.find((item) => item.context === 'telegram');
|
|
7
8
|
const token = config.token
|
|
@@ -112,11 +113,106 @@ export function formatInboundContent(msg) {
|
|
|
112
113
|
export function formatCallbackContent(query) {
|
|
113
114
|
return query.data ? `[action: ${query.data}]` : '[action]';
|
|
114
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
|
|
118
|
+
* Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
|
|
119
|
+
* 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
|
|
120
|
+
*/
|
|
121
|
+
export function formatInboundSegments(msg) {
|
|
122
|
+
const out = [];
|
|
123
|
+
if (msg.reply_to_message) {
|
|
124
|
+
out.push({
|
|
125
|
+
type: 'reply',
|
|
126
|
+
data: { message_id: String(msg.reply_to_message.message_id) },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const text = msg.text ?? msg.caption;
|
|
130
|
+
if (text)
|
|
131
|
+
out.push({ type: 'text', data: { text } });
|
|
132
|
+
if (msg.photo?.length) {
|
|
133
|
+
const largest = msg.photo[msg.photo.length - 1];
|
|
134
|
+
out.push({
|
|
135
|
+
type: 'image',
|
|
136
|
+
data: { media: { kind: 'file', value: largest.file_id } },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (msg.video) {
|
|
140
|
+
out.push({
|
|
141
|
+
type: 'video',
|
|
142
|
+
data: { media: { kind: 'file', value: msg.video.file_id } },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (msg.audio) {
|
|
146
|
+
out.push({
|
|
147
|
+
type: 'audio',
|
|
148
|
+
data: {
|
|
149
|
+
media: { kind: 'file', value: msg.audio.file_id },
|
|
150
|
+
...(msg.audio.title ? { name: msg.audio.title } : {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (msg.voice) {
|
|
155
|
+
out.push({
|
|
156
|
+
type: 'voice',
|
|
157
|
+
data: { media: { kind: 'file', value: msg.voice.file_id } },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (msg.document) {
|
|
161
|
+
out.push({
|
|
162
|
+
type: 'file',
|
|
163
|
+
data: {
|
|
164
|
+
media: {
|
|
165
|
+
kind: 'file',
|
|
166
|
+
value: msg.document.file_id,
|
|
167
|
+
...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
|
|
168
|
+
},
|
|
169
|
+
...(msg.document.file_name ? { name: msg.document.file_name } : {}),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (msg.sticker) {
|
|
174
|
+
out.push({
|
|
175
|
+
type: 'image',
|
|
176
|
+
data: {
|
|
177
|
+
media: { kind: 'file', value: msg.sticker.file_id },
|
|
178
|
+
...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* callback_query → action 段(Wave 1 C interactive 约定:
|
|
186
|
+
* {type:'action', data:{id, payload, sourceMessageId?}}),
|
|
187
|
+
* 与 formatCallbackContent / metadata.payload 同源。
|
|
188
|
+
*/
|
|
189
|
+
export function formatCallbackSegments(query) {
|
|
190
|
+
return [{
|
|
191
|
+
type: 'action',
|
|
192
|
+
data: {
|
|
193
|
+
id: query.id,
|
|
194
|
+
payload: query.data ?? '',
|
|
195
|
+
...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
|
|
196
|
+
},
|
|
197
|
+
}];
|
|
198
|
+
}
|
|
115
199
|
/**
|
|
116
200
|
* Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
|
|
117
201
|
* Segment canonicalization is intentionally not done here.
|
|
118
202
|
*/
|
|
119
203
|
export function formatOutboundActions(target, payload) {
|
|
204
|
+
return formatOutboundPlan(target, payload).actions;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
|
|
208
|
+
* 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
|
|
209
|
+
* kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
|
|
210
|
+
*/
|
|
211
|
+
export function formatOutboundPlan(target, payload) {
|
|
212
|
+
const uploads = [];
|
|
213
|
+
return { actions: buildOutboundActions(target, payload, uploads), uploads };
|
|
214
|
+
}
|
|
215
|
+
function buildOutboundActions(target, payload, uploads) {
|
|
120
216
|
const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
|
|
121
217
|
if (typeof payload === 'string') {
|
|
122
218
|
const text = payload.trim();
|
|
@@ -144,14 +240,41 @@ export function formatOutboundActions(target, payload) {
|
|
|
144
240
|
let keyboard;
|
|
145
241
|
const actions = [];
|
|
146
242
|
const replyParams = () => (replyTo != null ? { reply_parameters: { message_id: replyTo } } : {});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
243
|
+
/**
|
|
244
|
+
* 媒体来源归一:canonical `data.media` 优先,旧 wire 字段
|
|
245
|
+
* `{file_id,url,file,base64}` 经 mediaRefFromLegacyData 兼容。
|
|
246
|
+
* url/file → 字符串直发;base64/path → attach:// 占位并登记上传。
|
|
247
|
+
*/
|
|
248
|
+
const mediaSource = (data, defaultName) => {
|
|
249
|
+
const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
|
|
250
|
+
if (!media)
|
|
251
|
+
return undefined;
|
|
252
|
+
if (media.kind === 'file' || media.kind === 'url')
|
|
253
|
+
return media.value;
|
|
254
|
+
const named = data.name ?? data.filename;
|
|
255
|
+
let filename = typeof named === 'string' && named ? named : undefined;
|
|
256
|
+
if (!filename && media.kind === 'path') {
|
|
257
|
+
const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
258
|
+
filename = raw.split(/[\\/]/).filter(Boolean).pop();
|
|
259
|
+
}
|
|
260
|
+
const attachName = `attach${uploads.length}`;
|
|
261
|
+
uploads.push({
|
|
262
|
+
attachName,
|
|
263
|
+
filename: filename ?? defaultName,
|
|
264
|
+
source: media.kind === 'base64'
|
|
265
|
+
? {
|
|
266
|
+
kind: 'base64',
|
|
267
|
+
data: media.value.startsWith('base64://')
|
|
268
|
+
? media.value.slice('base64://'.length)
|
|
269
|
+
: media.value,
|
|
270
|
+
}
|
|
271
|
+
: {
|
|
272
|
+
kind: 'path',
|
|
273
|
+
path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
|
|
274
|
+
},
|
|
275
|
+
...(media.mime_type ? { mimeType: media.mime_type } : {}),
|
|
276
|
+
});
|
|
277
|
+
return `attach://${attachName}`;
|
|
155
278
|
};
|
|
156
279
|
for (const item of items) {
|
|
157
280
|
if (typeof item === 'string') {
|
|
@@ -190,7 +313,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
190
313
|
break;
|
|
191
314
|
}
|
|
192
315
|
case 'image': {
|
|
193
|
-
const photo = mediaSource(data);
|
|
316
|
+
const photo = mediaSource(data, 'image.png');
|
|
194
317
|
if (photo) {
|
|
195
318
|
actions.push({
|
|
196
319
|
method: 'sendPhoto',
|
|
@@ -206,7 +329,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
206
329
|
break;
|
|
207
330
|
}
|
|
208
331
|
case 'video': {
|
|
209
|
-
const video = mediaSource(data);
|
|
332
|
+
const video = mediaSource(data, 'video.mp4');
|
|
210
333
|
if (video) {
|
|
211
334
|
actions.push({
|
|
212
335
|
method: 'sendVideo',
|
|
@@ -222,7 +345,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
222
345
|
break;
|
|
223
346
|
}
|
|
224
347
|
case 'audio': {
|
|
225
|
-
const audio = mediaSource(data);
|
|
348
|
+
const audio = mediaSource(data, 'audio.mp3');
|
|
226
349
|
if (audio) {
|
|
227
350
|
actions.push({
|
|
228
351
|
method: 'sendAudio',
|
|
@@ -238,7 +361,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
238
361
|
break;
|
|
239
362
|
}
|
|
240
363
|
case 'voice': {
|
|
241
|
-
const voice = mediaSource(data);
|
|
364
|
+
const voice = mediaSource(data, 'voice.ogg');
|
|
242
365
|
if (voice) {
|
|
243
366
|
actions.push({
|
|
244
367
|
method: 'sendVoice',
|
|
@@ -254,7 +377,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
254
377
|
break;
|
|
255
378
|
}
|
|
256
379
|
case 'file': {
|
|
257
|
-
const document = mediaSource(data);
|
|
380
|
+
const document = mediaSource(data, 'file');
|
|
258
381
|
if (document) {
|
|
259
382
|
actions.push({
|
|
260
383
|
method: 'sendDocument',
|
|
@@ -270,7 +393,7 @@ export function formatOutboundActions(target, payload) {
|
|
|
270
393
|
break;
|
|
271
394
|
}
|
|
272
395
|
case 'sticker': {
|
|
273
|
-
const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data);
|
|
396
|
+
const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data, 'sticker.webp');
|
|
274
397
|
if (sticker) {
|
|
275
398
|
actions.push({
|
|
276
399
|
method: 'sendSticker',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const telegramEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `telegram endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { telegramRuntimeStateToken } from './telegram-runtime-state.js';
|
|
8
|
+
export const telegramEndpointCommands = createEndpointCommands({
|
|
9
|
+
adapterKey: 'telegram',
|
|
10
|
+
adapterDisplayName: 'Telegram',
|
|
11
|
+
fields: [
|
|
12
|
+
{ key: 'token', required: true, env: true, description: 'Telegram bot token' },
|
|
13
|
+
],
|
|
14
|
+
running: (use) => use(telegramRuntimeStateToken).endpoints.values(),
|
|
15
|
+
describeEntry: (entry) => `token: ${String(entry.token)}`,
|
|
16
|
+
}, defineCommand);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const telegramRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `telegram endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram webhook HTTP: secret token → parse → handle update.
|
|
3
|
-
*/
|
|
4
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
2
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
3
|
import { type ResolvedTelegramConfig, type TelegramUpdate } from './protocol.js';
|
|
4
|
+
/** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
|
|
5
|
+
export declare function safeTokenEqual(a: string, b: string): boolean;
|
|
7
6
|
export interface TelegramWebhookHandler {
|
|
8
7
|
readonly config: ResolvedTelegramConfig;
|
|
9
8
|
readonly isOpen: boolean;
|
package/lib/webhook.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram webhook HTTP: secret token → parse → handle update.
|
|
3
|
+
*/
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
1
5
|
import { getLogger } from '@zhin.js/logger';
|
|
2
6
|
import { readTextBody } from './protocol.js';
|
|
3
7
|
const logger = getLogger('telegram');
|
|
8
|
+
/** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
|
|
9
|
+
export function safeTokenEqual(a, b) {
|
|
10
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
11
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
12
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
13
|
+
}
|
|
4
14
|
export function registerTelegramWebhookRoutes(http, handler) {
|
|
5
15
|
const path = handler.config.webhook.path;
|
|
6
16
|
return [
|
|
@@ -15,7 +25,7 @@ export async function handleTelegramWebhookRequest(request, response, handler) {
|
|
|
15
25
|
if (secret) {
|
|
16
26
|
const header = request.headers['x-telegram-bot-api-secret-token'];
|
|
17
27
|
const token = Array.isArray(header) ? header[0] : header;
|
|
18
|
-
if (token
|
|
28
|
+
if (!token || !safeTokenEqual(token, secret)) {
|
|
19
29
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
20
30
|
response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
|
|
21
31
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-telegram",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
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,19 +32,20 @@
|
|
|
32
32
|
"directory": "plugins/adapters/telegram"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zhin.js/adapter": "1.1.
|
|
36
|
-
"@zhin.js/
|
|
37
|
-
"@zhin.js/
|
|
35
|
+
"@zhin.js/adapter": "1.1.1",
|
|
36
|
+
"@zhin.js/command": "1.0.3",
|
|
37
|
+
"@zhin.js/core": "1.4.1",
|
|
38
|
+
"@zhin.js/host-http": "1.0.3",
|
|
38
39
|
"@zhin.js/logger": "1.0.75",
|
|
39
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
40
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
43
|
"zod": "^4.0.0",
|
|
43
|
-
"@zhin.js/adapter": "1.1.
|
|
44
|
-
"@zhin.js/agent": "1.0.
|
|
45
|
-
"@zhin.js/core": "1.4.
|
|
46
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
47
|
-
"zhin.js": "5.0.
|
|
44
|
+
"@zhin.js/adapter": "1.1.1",
|
|
45
|
+
"@zhin.js/agent": "1.0.6",
|
|
46
|
+
"@zhin.js/core": "1.4.1",
|
|
47
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
48
|
+
"zhin.js": "5.0.1"
|
|
48
49
|
},
|
|
49
50
|
"peerDependenciesMeta": {
|
|
50
51
|
"zhin.js": {
|
|
@@ -62,12 +63,13 @@
|
|
|
62
63
|
"typescript": "^6.0.3",
|
|
63
64
|
"vitest": "^4.1.10",
|
|
64
65
|
"zod": "^4.4.3",
|
|
65
|
-
"@zhin.js/agent": "1.0.
|
|
66
|
-
"@zhin.js/host-http": "1.0.
|
|
67
|
-
"zhin.js": "5.0.
|
|
66
|
+
"@zhin.js/agent": "1.0.6",
|
|
67
|
+
"@zhin.js/host-http": "1.0.3",
|
|
68
|
+
"zhin.js": "5.0.1"
|
|
68
69
|
},
|
|
69
70
|
"files": [
|
|
70
71
|
"adapters",
|
|
72
|
+
"commands",
|
|
71
73
|
"plugin.ts",
|
|
72
74
|
"schema.json",
|
|
73
75
|
"src",
|
|
@@ -93,6 +95,10 @@
|
|
|
93
95
|
{
|
|
94
96
|
"package": "@zhin.js/adapter",
|
|
95
97
|
"api": "^1.0.0"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"package": "@zhin.js/command",
|
|
101
|
+
"api": "^1.0.0"
|
|
96
102
|
}
|
|
97
103
|
],
|
|
98
104
|
"plugins": []
|
package/plugin.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
1
2
|
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
2
3
|
import { registerTelegramPlatformPermitChecker } from './src/platform-permit.js';
|
|
4
|
+
import { telegramRuntimeStateToken } from './src/telegram-runtime-state.js';
|
|
3
5
|
|
|
4
6
|
export default definePlugin({
|
|
5
7
|
name: 'telegram',
|
|
6
8
|
metadata: {
|
|
7
9
|
displayName: 'Telegram Bot API Adapter',
|
|
8
10
|
},
|
|
9
|
-
setup() {
|
|
11
|
+
setup(context) {
|
|
12
|
+
// 运行中 endpoint 注册表(telegram endpoint list 的"运行中"数据源)
|
|
13
|
+
context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState());
|
|
10
14
|
// 平台权限门禁:chat_creator / chat_administrator / pin_messages 等(agent 工具 platformPermit)
|
|
11
15
|
return registerTelegramPlatformPermitChecker();
|
|
12
16
|
},
|
package/src/endpoint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
|
|
3
3
|
*/
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
4
5
|
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
5
6
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
7
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
@@ -12,14 +13,17 @@ import {
|
|
|
12
13
|
botApiUrl,
|
|
13
14
|
buildWebhookUrl,
|
|
14
15
|
formatCallbackContent,
|
|
16
|
+
formatCallbackSegments,
|
|
15
17
|
formatInboundContent,
|
|
16
|
-
|
|
18
|
+
formatInboundSegments,
|
|
19
|
+
formatOutboundPlan,
|
|
17
20
|
resolveChannel,
|
|
18
21
|
senderDisplayName,
|
|
19
22
|
type ResolvedTelegramConfig,
|
|
20
23
|
type TelegramCallbackQuery,
|
|
21
24
|
type TelegramChatMember,
|
|
22
25
|
type TelegramMessage,
|
|
26
|
+
type TelegramOutboundUpload,
|
|
23
27
|
type TelegramUpdate,
|
|
24
28
|
} from './protocol.js';
|
|
25
29
|
import { registerTelegramAgentEndpoint } from './telegram-agent-deps.js';
|
|
@@ -41,7 +45,7 @@ export type TelegramFetch = (
|
|
|
41
45
|
init?: {
|
|
42
46
|
readonly method?: string;
|
|
43
47
|
readonly headers?: Record<string, string>;
|
|
44
|
-
readonly body?: string;
|
|
48
|
+
readonly body?: string | FormData;
|
|
45
49
|
readonly signal?: AbortSignal;
|
|
46
50
|
},
|
|
47
51
|
) => Promise<{
|
|
@@ -70,6 +74,11 @@ interface TelegramApiErr {
|
|
|
70
74
|
readonly error_code?: number;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
|
|
79
|
+
* 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
|
|
80
|
+
* 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
|
|
81
|
+
*/
|
|
73
82
|
export class TelegramEndpoint implements EndpointInstance {
|
|
74
83
|
readonly #options: TelegramEndpointOptions;
|
|
75
84
|
readonly #fetch: TelegramFetch;
|
|
@@ -125,6 +134,15 @@ export class TelegramEndpoint implements EndpointInstance {
|
|
|
125
134
|
}
|
|
126
135
|
this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
|
|
127
136
|
const webhook = this.#options.config.webhook!;
|
|
137
|
+
if (!webhook.secretToken) {
|
|
138
|
+
// 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
|
|
139
|
+
logger.warn(formatCompact({
|
|
140
|
+
op: 'webhook_no_secret',
|
|
141
|
+
endpoint: this.#options.config.name,
|
|
142
|
+
path: webhook.path,
|
|
143
|
+
hint: 'set webhook.secretToken to authenticate Telegram callbacks',
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
128
146
|
const url = buildWebhookUrl(webhook);
|
|
129
147
|
await this.callApi('setWebhook', {
|
|
130
148
|
url,
|
|
@@ -182,15 +200,50 @@ export class TelegramEndpoint implements EndpointInstance {
|
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
|
|
185
|
-
const
|
|
203
|
+
const plan = formatOutboundPlan(target, payload);
|
|
186
204
|
let lastId = '';
|
|
187
|
-
for (const action of actions) {
|
|
188
|
-
const
|
|
205
|
+
for (const action of plan.actions) {
|
|
206
|
+
const form = await this.#buildUploadForm(action.params, plan.uploads);
|
|
207
|
+
const result = form
|
|
208
|
+
? await this.callApiForm<{ message_id?: number }>(action.method, form)
|
|
209
|
+
: await this.callApi<{ message_id?: number }>(action.method, action.params);
|
|
189
210
|
if (result.message_id != null) lastId = String(result.message_id);
|
|
190
211
|
}
|
|
191
212
|
return lastId || `telegram-${Date.now()}`;
|
|
192
213
|
}
|
|
193
214
|
|
|
215
|
+
/**
|
|
216
|
+
* 含 `attach://` 占位的媒体参数 → multipart/form-data:
|
|
217
|
+
* 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
|
|
218
|
+
* (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
|
|
219
|
+
*/
|
|
220
|
+
async #buildUploadForm(
|
|
221
|
+
params: Record<string, unknown>,
|
|
222
|
+
uploads: readonly TelegramOutboundUpload[],
|
|
223
|
+
): Promise<FormData | undefined> {
|
|
224
|
+
const values = Object.values(params);
|
|
225
|
+
if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://'))) return undefined;
|
|
226
|
+
const form = new FormData();
|
|
227
|
+
for (const [key, value] of Object.entries(params)) {
|
|
228
|
+
if (value == null) continue;
|
|
229
|
+
if (typeof value === 'string' && value.startsWith('attach://')) {
|
|
230
|
+
const upload = uploads.find((item) => `attach://${item.attachName}` === value);
|
|
231
|
+
if (!upload) throw new Error(`Telegram upload 未登记: ${value}`);
|
|
232
|
+
const data = upload.source.kind === 'base64'
|
|
233
|
+
? Buffer.from(upload.source.data, 'base64')
|
|
234
|
+
: await readFile(upload.source.path);
|
|
235
|
+
form.append(
|
|
236
|
+
key,
|
|
237
|
+
new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined),
|
|
238
|
+
upload.filename,
|
|
239
|
+
);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
243
|
+
}
|
|
244
|
+
return form;
|
|
245
|
+
}
|
|
246
|
+
|
|
194
247
|
/** Test / internal: admit a message when open. */
|
|
195
248
|
admit(msg: TelegramMessage): void {
|
|
196
249
|
if (!this.#open) return;
|
|
@@ -212,6 +265,7 @@ export class TelegramEndpoint implements EndpointInstance {
|
|
|
212
265
|
adapter: this.#options.id,
|
|
213
266
|
target: channelId,
|
|
214
267
|
content: formatInboundContent(msg),
|
|
268
|
+
segments: formatInboundSegments(msg),
|
|
215
269
|
sender: senderDisplayName(msg.from),
|
|
216
270
|
id: String(msg.message_id),
|
|
217
271
|
metadata: Object.freeze({
|
|
@@ -290,6 +344,7 @@ export class TelegramEndpoint implements EndpointInstance {
|
|
|
290
344
|
adapter: this.#options.id,
|
|
291
345
|
target: channelId,
|
|
292
346
|
content: formatCallbackContent(query),
|
|
347
|
+
segments: formatCallbackSegments(query),
|
|
293
348
|
sender: senderDisplayName(query.from),
|
|
294
349
|
id: query.id,
|
|
295
350
|
metadata: Object.freeze({
|
|
@@ -336,6 +391,28 @@ export class TelegramEndpoint implements EndpointInstance {
|
|
|
336
391
|
body: JSON.stringify(params),
|
|
337
392
|
signal,
|
|
338
393
|
});
|
|
394
|
+
return this.#parseApiResponse<T>(method, response);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
|
|
398
|
+
async callApiForm<T = unknown>(
|
|
399
|
+
method: string,
|
|
400
|
+
form: FormData,
|
|
401
|
+
signal?: AbortSignal,
|
|
402
|
+
): Promise<T> {
|
|
403
|
+
const url = botApiUrl(this.#options.config, method);
|
|
404
|
+
const response = await this.#fetch(url, {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
body: form,
|
|
407
|
+
signal,
|
|
408
|
+
});
|
|
409
|
+
return this.#parseApiResponse<T>(method, response);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async #parseApiResponse<T>(
|
|
413
|
+
method: string,
|
|
414
|
+
response: { readonly status: number; text(): Promise<string> },
|
|
415
|
+
): Promise<T> {
|
|
339
416
|
const text = await response.text();
|
|
340
417
|
let body: TelegramApiOk<T> | TelegramApiErr;
|
|
341
418
|
try {
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
formatCallbackContent,
|
|
10
10
|
formatInboundContent,
|
|
11
11
|
formatOutboundActions,
|
|
12
|
+
formatOutboundPlan,
|
|
12
13
|
normalizeWebhookPath,
|
|
13
14
|
resolveChannel,
|
|
14
15
|
resolveTelegramConfig,
|
|
@@ -20,6 +21,8 @@ export {
|
|
|
20
21
|
type TelegramChatMember,
|
|
21
22
|
type TelegramMessage,
|
|
22
23
|
type TelegramOutboundAction,
|
|
24
|
+
type TelegramOutboundPlan,
|
|
25
|
+
type TelegramOutboundUpload,
|
|
23
26
|
type TelegramUpdate,
|
|
24
27
|
type TelegramUser,
|
|
25
28
|
type TelegramWireSegment,
|
package/src/polling.ts
CHANGED
|
@@ -47,11 +47,12 @@ export async function runTelegramPollLoop(
|
|
|
47
47
|
ok: false,
|
|
48
48
|
error: err instanceof Error ? err.message : String(err),
|
|
49
49
|
}));
|
|
50
|
+
// 不在退避后清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS,
|
|
51
|
+
// 保持计数才能让 BACKOFF_DELAY_MS 持续生效(成功时上面才清零)。
|
|
50
52
|
await sleep(
|
|
51
53
|
consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ? BACKOFF_DELAY_MS : RETRY_DELAY_MS,
|
|
52
54
|
abortSignal,
|
|
53
55
|
);
|
|
54
|
-
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) consecutiveFailures = 0;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { IncomingMessage } from 'node:http';
|
|
7
|
+
import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
|
|
8
|
+
import type { Segment } from '@zhin.js/core/runtime';
|
|
7
9
|
|
|
8
10
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
9
11
|
export interface TelegramAdapterConfig {
|
|
@@ -355,6 +357,108 @@ export function formatCallbackContent(query: TelegramCallbackQuery): string {
|
|
|
355
357
|
return query.data ? `[action: ${query.data}]` : '[action]';
|
|
356
358
|
}
|
|
357
359
|
|
|
360
|
+
/**
|
|
361
|
+
* 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
|
|
362
|
+
* Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
|
|
363
|
+
* 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
|
|
364
|
+
*/
|
|
365
|
+
export function formatInboundSegments(msg: TelegramMessage): Segment[] {
|
|
366
|
+
const out: Segment[] = [];
|
|
367
|
+
if (msg.reply_to_message) {
|
|
368
|
+
out.push({
|
|
369
|
+
type: 'reply',
|
|
370
|
+
data: { message_id: String(msg.reply_to_message.message_id) },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const text = msg.text ?? msg.caption;
|
|
374
|
+
if (text) out.push({ type: 'text', data: { text } });
|
|
375
|
+
if (msg.photo?.length) {
|
|
376
|
+
const largest = msg.photo[msg.photo.length - 1]!;
|
|
377
|
+
out.push({
|
|
378
|
+
type: 'image',
|
|
379
|
+
data: { media: { kind: 'file', value: largest.file_id } },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (msg.video) {
|
|
383
|
+
out.push({
|
|
384
|
+
type: 'video',
|
|
385
|
+
data: { media: { kind: 'file', value: msg.video.file_id } },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (msg.audio) {
|
|
389
|
+
out.push({
|
|
390
|
+
type: 'audio',
|
|
391
|
+
data: {
|
|
392
|
+
media: { kind: 'file', value: msg.audio.file_id },
|
|
393
|
+
...(msg.audio.title ? { name: msg.audio.title } : {}),
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (msg.voice) {
|
|
398
|
+
out.push({
|
|
399
|
+
type: 'voice',
|
|
400
|
+
data: { media: { kind: 'file', value: msg.voice.file_id } },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (msg.document) {
|
|
404
|
+
out.push({
|
|
405
|
+
type: 'file',
|
|
406
|
+
data: {
|
|
407
|
+
media: {
|
|
408
|
+
kind: 'file',
|
|
409
|
+
value: msg.document.file_id,
|
|
410
|
+
...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
|
|
411
|
+
},
|
|
412
|
+
...(msg.document.file_name ? { name: msg.document.file_name } : {}),
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (msg.sticker) {
|
|
417
|
+
out.push({
|
|
418
|
+
type: 'image',
|
|
419
|
+
data: {
|
|
420
|
+
media: { kind: 'file', value: msg.sticker.file_id },
|
|
421
|
+
...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* callback_query → action 段(Wave 1 C interactive 约定:
|
|
430
|
+
* {type:'action', data:{id, payload, sourceMessageId?}}),
|
|
431
|
+
* 与 formatCallbackContent / metadata.payload 同源。
|
|
432
|
+
*/
|
|
433
|
+
export function formatCallbackSegments(query: TelegramCallbackQuery): Segment[] {
|
|
434
|
+
return [{
|
|
435
|
+
type: 'action',
|
|
436
|
+
data: {
|
|
437
|
+
id: query.id,
|
|
438
|
+
payload: query.data ?? '',
|
|
439
|
+
...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
|
|
440
|
+
},
|
|
441
|
+
}];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 出站待上传媒体(base64 / 本地路径 MediaRef 物化为 multipart 附件)。
|
|
446
|
+
* params 里以 `attach://<attachName>` 占位,endpoint 发送时替换为文件 part。
|
|
447
|
+
*/
|
|
448
|
+
export interface TelegramOutboundUpload {
|
|
449
|
+
readonly attachName: string;
|
|
450
|
+
readonly filename: string;
|
|
451
|
+
readonly source:
|
|
452
|
+
| { readonly kind: 'base64'; readonly data: string }
|
|
453
|
+
| { readonly kind: 'path'; readonly path: string };
|
|
454
|
+
readonly mimeType?: string;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface TelegramOutboundPlan {
|
|
458
|
+
readonly actions: TelegramOutboundAction[];
|
|
459
|
+
readonly uploads: readonly TelegramOutboundUpload[];
|
|
460
|
+
}
|
|
461
|
+
|
|
358
462
|
/**
|
|
359
463
|
* Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
|
|
360
464
|
* Segment canonicalization is intentionally not done here.
|
|
@@ -362,6 +466,27 @@ export function formatCallbackContent(query: TelegramCallbackQuery): string {
|
|
|
362
466
|
export function formatOutboundActions(
|
|
363
467
|
target: string | number,
|
|
364
468
|
payload: unknown,
|
|
469
|
+
): TelegramOutboundAction[] {
|
|
470
|
+
return formatOutboundPlan(target, payload).actions;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
|
|
475
|
+
* 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
|
|
476
|
+
* kind=url/file 与旧 wire 字段(file_id/url)保持字符串直发。
|
|
477
|
+
*/
|
|
478
|
+
export function formatOutboundPlan(
|
|
479
|
+
target: string | number,
|
|
480
|
+
payload: unknown,
|
|
481
|
+
): TelegramOutboundPlan {
|
|
482
|
+
const uploads: TelegramOutboundUpload[] = [];
|
|
483
|
+
return { actions: buildOutboundActions(target, payload, uploads), uploads };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function buildOutboundActions(
|
|
487
|
+
target: string | number,
|
|
488
|
+
payload: unknown,
|
|
489
|
+
uploads: TelegramOutboundUpload[],
|
|
365
490
|
): TelegramOutboundAction[] {
|
|
366
491
|
const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
|
|
367
492
|
if (typeof payload === 'string') {
|
|
@@ -395,11 +520,39 @@ export function formatOutboundActions(
|
|
|
395
520
|
replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}
|
|
396
521
|
);
|
|
397
522
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
523
|
+
/**
|
|
524
|
+
* 媒体来源归一:canonical `data.media` 优先,旧 wire 字段
|
|
525
|
+
* `{file_id,url,file,base64}` 经 mediaRefFromLegacyData 兼容。
|
|
526
|
+
* url/file → 字符串直发;base64/path → attach:// 占位并登记上传。
|
|
527
|
+
*/
|
|
528
|
+
const mediaSource = (data: Record<string, unknown>, defaultName: string): string | undefined => {
|
|
529
|
+
const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
|
|
530
|
+
if (!media) return undefined;
|
|
531
|
+
if (media.kind === 'file' || media.kind === 'url') return media.value;
|
|
532
|
+
const named = data.name ?? data.filename;
|
|
533
|
+
let filename = typeof named === 'string' && named ? named : undefined;
|
|
534
|
+
if (!filename && media.kind === 'path') {
|
|
535
|
+
const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
536
|
+
filename = raw.split(/[\\/]/).filter(Boolean).pop();
|
|
537
|
+
}
|
|
538
|
+
const attachName = `attach${uploads.length}`;
|
|
539
|
+
uploads.push({
|
|
540
|
+
attachName,
|
|
541
|
+
filename: filename ?? defaultName,
|
|
542
|
+
source: media.kind === 'base64'
|
|
543
|
+
? {
|
|
544
|
+
kind: 'base64',
|
|
545
|
+
data: media.value.startsWith('base64://')
|
|
546
|
+
? media.value.slice('base64://'.length)
|
|
547
|
+
: media.value,
|
|
548
|
+
}
|
|
549
|
+
: {
|
|
550
|
+
kind: 'path',
|
|
551
|
+
path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
|
|
552
|
+
},
|
|
553
|
+
...(media.mime_type ? { mimeType: media.mime_type } : {}),
|
|
554
|
+
});
|
|
555
|
+
return `attach://${attachName}`;
|
|
403
556
|
};
|
|
404
557
|
|
|
405
558
|
for (const item of items) {
|
|
@@ -437,7 +590,7 @@ export function formatOutboundActions(
|
|
|
437
590
|
break;
|
|
438
591
|
}
|
|
439
592
|
case 'image': {
|
|
440
|
-
const photo = mediaSource(data);
|
|
593
|
+
const photo = mediaSource(data, 'image.png');
|
|
441
594
|
if (photo) {
|
|
442
595
|
actions.push({
|
|
443
596
|
method: 'sendPhoto',
|
|
@@ -453,7 +606,7 @@ export function formatOutboundActions(
|
|
|
453
606
|
break;
|
|
454
607
|
}
|
|
455
608
|
case 'video': {
|
|
456
|
-
const video = mediaSource(data);
|
|
609
|
+
const video = mediaSource(data, 'video.mp4');
|
|
457
610
|
if (video) {
|
|
458
611
|
actions.push({
|
|
459
612
|
method: 'sendVideo',
|
|
@@ -469,7 +622,7 @@ export function formatOutboundActions(
|
|
|
469
622
|
break;
|
|
470
623
|
}
|
|
471
624
|
case 'audio': {
|
|
472
|
-
const audio = mediaSource(data);
|
|
625
|
+
const audio = mediaSource(data, 'audio.mp3');
|
|
473
626
|
if (audio) {
|
|
474
627
|
actions.push({
|
|
475
628
|
method: 'sendAudio',
|
|
@@ -485,7 +638,7 @@ export function formatOutboundActions(
|
|
|
485
638
|
break;
|
|
486
639
|
}
|
|
487
640
|
case 'voice': {
|
|
488
|
-
const voice = mediaSource(data);
|
|
641
|
+
const voice = mediaSource(data, 'voice.ogg');
|
|
489
642
|
if (voice) {
|
|
490
643
|
actions.push({
|
|
491
644
|
method: 'sendVoice',
|
|
@@ -501,7 +654,7 @@ export function formatOutboundActions(
|
|
|
501
654
|
break;
|
|
502
655
|
}
|
|
503
656
|
case 'file': {
|
|
504
|
-
const document = mediaSource(data);
|
|
657
|
+
const document = mediaSource(data, 'file');
|
|
505
658
|
if (document) {
|
|
506
659
|
actions.push({
|
|
507
660
|
method: 'sendDocument',
|
|
@@ -517,7 +670,7 @@ export function formatOutboundActions(
|
|
|
517
670
|
break;
|
|
518
671
|
}
|
|
519
672
|
case 'sticker': {
|
|
520
|
-
const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data);
|
|
673
|
+
const sticker = typeof data.file_id === 'string' ? data.file_id : mediaSource(data, 'sticker.webp');
|
|
521
674
|
if (sticker) {
|
|
522
675
|
actions.push({
|
|
523
676
|
method: 'sendSticker',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `telegram endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { telegramRuntimeStateToken } from './telegram-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const telegramEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'telegram',
|
|
11
|
+
adapterDisplayName: 'Telegram',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'token', required: true, env: true, description: 'Telegram bot token' },
|
|
14
|
+
],
|
|
15
|
+
running: (use) => use(telegramRuntimeStateToken).endpoints.values(),
|
|
16
|
+
describeEntry: (entry) => `token: ${String(entry.token)}`,
|
|
17
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `telegram endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');
|
package/src/webhook.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram webhook HTTP: secret token → parse → handle update.
|
|
3
3
|
*/
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
6
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
7
|
import { getLogger } from '@zhin.js/logger';
|
|
@@ -8,6 +9,13 @@ import { readTextBody, type ResolvedTelegramConfig, type TelegramUpdate } from '
|
|
|
8
9
|
|
|
9
10
|
const logger = getLogger('telegram');
|
|
10
11
|
|
|
12
|
+
/** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
|
|
13
|
+
export function safeTokenEqual(a: string, b: string): boolean {
|
|
14
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
15
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
16
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export interface TelegramWebhookHandler {
|
|
12
20
|
readonly config: ResolvedTelegramConfig;
|
|
13
21
|
readonly isOpen: boolean;
|
|
@@ -36,7 +44,7 @@ export async function handleTelegramWebhookRequest(
|
|
|
36
44
|
if (secret) {
|
|
37
45
|
const header = request.headers['x-telegram-bot-api-secret-token'];
|
|
38
46
|
const token = Array.isArray(header) ? header[0] : header;
|
|
39
|
-
if (token
|
|
47
|
+
if (!token || !safeTokenEqual(token, secret)) {
|
|
40
48
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
41
49
|
response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
|
|
42
50
|
return;
|