@zhin.js/adapter-telegram 5.0.3 → 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 +63 -0
- package/README.md +4 -3
- 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/schema.json +48 -13
- 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/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": "
|
|
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.
|
|
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.
|
|
40
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
43
|
"zod": "^4.0.0",
|
|
43
|
-
"@zhin.js/adapter": "1.
|
|
44
|
-
"@zhin.js/agent": "1.0.
|
|
45
|
-
"@zhin.js/core": "1.
|
|
46
|
-
"@zhin.js/plugin-runtime": "1.
|
|
47
|
-
"zhin.js": "
|
|
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": "
|
|
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/schema.json
CHANGED
|
@@ -3,13 +3,6 @@
|
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
5
|
"properties": {
|
|
6
|
-
"name": {
|
|
7
|
-
"type": "string",
|
|
8
|
-
"default": "telegram-bot"
|
|
9
|
-
},
|
|
10
|
-
"token": {
|
|
11
|
-
"type": "string"
|
|
12
|
-
},
|
|
13
6
|
"polling": {
|
|
14
7
|
"type": "boolean",
|
|
15
8
|
"default": true,
|
|
@@ -20,20 +13,62 @@
|
|
|
20
13
|
"additionalProperties": false,
|
|
21
14
|
"description": "Webhook settings when polling is false.",
|
|
22
15
|
"properties": {
|
|
23
|
-
"domain": {
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
"domain": {
|
|
17
|
+
"type": "string"
|
|
18
|
+
},
|
|
19
|
+
"path": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"default": "/telegram/webhook"
|
|
22
|
+
},
|
|
23
|
+
"secretToken": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Verified via X-Telegram-Bot-Api-Secret-Token."
|
|
26
|
+
}
|
|
26
27
|
}
|
|
27
28
|
},
|
|
28
29
|
"allowedUpdates": {
|
|
29
30
|
"type": "array",
|
|
30
|
-
"items": {
|
|
31
|
-
|
|
31
|
+
"items": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"default": [
|
|
35
|
+
"message",
|
|
36
|
+
"callback_query"
|
|
37
|
+
]
|
|
32
38
|
},
|
|
33
39
|
"apiBaseUrl": {
|
|
34
40
|
"type": "string",
|
|
35
41
|
"default": "https://api.telegram.org"
|
|
42
|
+
},
|
|
43
|
+
"endpoints": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
|
|
46
|
+
"items": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": true,
|
|
49
|
+
"properties": {
|
|
50
|
+
"name": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Telegram bot name"
|
|
53
|
+
},
|
|
54
|
+
"token": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Telegram bot token"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": [
|
|
60
|
+
"name",
|
|
61
|
+
"token"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"commandPrefix": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"default": "",
|
|
68
|
+
"description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
|
|
36
69
|
}
|
|
37
70
|
},
|
|
38
|
-
"required": [
|
|
71
|
+
"required": [
|
|
72
|
+
"endpoints"
|
|
73
|
+
]
|
|
39
74
|
}
|
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');
|