@zhin.js/adapter-telegram 1.0.69 → 1.1.0
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 +816 -17
- package/README.md +106 -77
- package/adapters/telegram.js +34 -0
- package/adapters/telegram.ts +39 -0
- package/agent/PERMITS.md +24 -0
- package/agent/tools/create_invite.ts +18 -0
- package/agent/tools/list_admins.ts +24 -0
- package/agent/tools/member_count.ts +16 -0
- package/agent/tools/pin_message.ts +19 -0
- package/agent/tools/react.ts +18 -0
- package/agent/tools/send_poll.ts +32 -0
- package/agent/tools/send_sticker.ts +17 -0
- package/agent/tools/set_description.ts +17 -0
- package/agent/tools/set_permissions.ts +31 -0
- package/agent/tools/unpin_message.ts +19 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +112 -0
- package/lib/endpoint.js +535 -0
- package/lib/index.d.ts +4 -18
- package/lib/index.js +4 -455
- package/lib/markdown-to-html.d.ts +9 -0
- package/lib/markdown-to-html.js +75 -0
- package/lib/platform-permit.d.ts +17 -0
- package/lib/platform-permit.js +51 -0
- package/lib/polling.d.ts +9 -0
- package/lib/polling.js +57 -0
- package/lib/protocol.d.ts +299 -0
- package/lib/protocol.js +474 -0
- 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 +12 -0
- package/lib/webhook.js +55 -0
- package/package.json +59 -28
- package/plugin.js +19 -0
- package/schema.json +110 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +679 -0
- package/src/index.ts +39 -426
- package/src/markdown-to-html.ts +86 -0
- package/src/platform-permit.ts +65 -0
- package/src/polling.ts +76 -0
- package/src/protocol.ts +767 -0
- package/src/telegram-endpoint-commands.ts +17 -0
- package/src/telegram-runtime-state.ts +7 -0
- package/src/webhook.ts +75 -0
- package/client/Dashboard.tsx +0 -295
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -17
- package/dist/index.js +0 -32
- package/lib/adapter.d.ts +0 -18
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -55
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -140
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -866
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -29
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -64
- package/src/bot.ts +0 -983
- package/src/types.ts +0 -32
- /package/{skills/telegram/SKILL.md → agent/skills/telegram.md} +0 -0
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
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
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
|
|
8
|
+
const logger = getLogger('telegram');
|
|
9
|
+
export function resolveTelegramConfig(config = {}) {
|
|
10
|
+
const entry = config.endpoints?.find((item) => item.context === 'telegram');
|
|
11
|
+
const token = config.token
|
|
12
|
+
?? entry?.token
|
|
13
|
+
?? process.env.TELEGRAM_TOKEN
|
|
14
|
+
?? process.env.TELEGRAM_BOT_TOKEN;
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw new TypeError('Telegram adapter requires token (plugins.<key>.token or endpoints with context: telegram)');
|
|
17
|
+
}
|
|
18
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
19
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
20
|
+
|| process.env.TELEGRAM_BOT_NAME
|
|
21
|
+
|| 'telegram-bot';
|
|
22
|
+
const polling = config.polling ?? entry?.polling;
|
|
23
|
+
const webhookSource = config.webhook ?? entry?.webhook;
|
|
24
|
+
// Match legacy: polling defaults true; webhook only when polling === false.
|
|
25
|
+
const mode = polling === false ? 'webhook' : 'polling';
|
|
26
|
+
const apiBaseUrl = (config.apiBaseUrl
|
|
27
|
+
?? entry?.apiBaseUrl
|
|
28
|
+
?? 'https://api.telegram.org').replace(/\/$/, '');
|
|
29
|
+
const allowedUpdates = config.allowedUpdates
|
|
30
|
+
?? entry?.allowedUpdates
|
|
31
|
+
?? ['message', 'callback_query'];
|
|
32
|
+
const webhook = mode === 'webhook'
|
|
33
|
+
? {
|
|
34
|
+
domain: webhookSource?.domain ?? '',
|
|
35
|
+
path: normalizeWebhookPath(webhookSource?.path ?? '/telegram/webhook'),
|
|
36
|
+
secretToken: webhookSource?.secretToken
|
|
37
|
+
?? process.env.TELEGRAM_WEBHOOK_SECRET
|
|
38
|
+
?? undefined,
|
|
39
|
+
}
|
|
40
|
+
: undefined;
|
|
41
|
+
return {
|
|
42
|
+
context: 'telegram',
|
|
43
|
+
id,
|
|
44
|
+
token,
|
|
45
|
+
mode,
|
|
46
|
+
allowedUpdates: [...allowedUpdates],
|
|
47
|
+
apiBaseUrl,
|
|
48
|
+
webhook,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function normalizeWebhookPath(path) {
|
|
52
|
+
const trimmed = path.trim() || '/telegram/webhook';
|
|
53
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
54
|
+
}
|
|
55
|
+
export function buildWebhookUrl(webhook) {
|
|
56
|
+
const domain = webhook.domain.replace(/\/$/, '');
|
|
57
|
+
if (!domain) {
|
|
58
|
+
throw new TypeError('Telegram webhook mode requires webhook.domain');
|
|
59
|
+
}
|
|
60
|
+
return `${domain}${webhook.path}`;
|
|
61
|
+
}
|
|
62
|
+
export async function readTextBody(request, options = {}) {
|
|
63
|
+
const limit = options.limit ?? 1_048_576;
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let size = 0;
|
|
66
|
+
for await (const chunk of request) {
|
|
67
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
68
|
+
size += buffer.length;
|
|
69
|
+
if (size > limit) {
|
|
70
|
+
request.destroy();
|
|
71
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
72
|
+
}
|
|
73
|
+
chunks.push(buffer);
|
|
74
|
+
}
|
|
75
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
76
|
+
}
|
|
77
|
+
export function botApiUrl(config, method) {
|
|
78
|
+
return `${config.apiBaseUrl}/bot${config.token}/${method}`;
|
|
79
|
+
}
|
|
80
|
+
/** Telegram chat.type → canonical 会话 kind(supergroup 即 group,无容器层级故无 parent)。 */
|
|
81
|
+
export function resolveTelegramChannelType(chatType) {
|
|
82
|
+
if (chatType === 'private')
|
|
83
|
+
return 'private';
|
|
84
|
+
if (chatType === 'channel')
|
|
85
|
+
return 'channel';
|
|
86
|
+
return 'group';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 入站归一化 → ConversationRef:Telegram 无 guild/群组容器层级,
|
|
90
|
+
* kind 直取 chat.type(private/group/supergroup/channel),无 parent。
|
|
91
|
+
*/
|
|
92
|
+
export function telegramInboundConversation(endpointKey, chat) {
|
|
93
|
+
return {
|
|
94
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
95
|
+
kind: resolveTelegramChannelType(chat.type),
|
|
96
|
+
id: String(chat.id),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function senderDisplayName(user) {
|
|
100
|
+
if (!user)
|
|
101
|
+
return 'Unknown';
|
|
102
|
+
return user.username || user.first_name || String(user.id);
|
|
103
|
+
}
|
|
104
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
105
|
+
export function formatInboundContent(msg) {
|
|
106
|
+
if (msg.text)
|
|
107
|
+
return msg.text;
|
|
108
|
+
if (msg.caption)
|
|
109
|
+
return msg.caption;
|
|
110
|
+
if (msg.sticker) {
|
|
111
|
+
return msg.sticker.emoji ? `[sticker: ${msg.sticker.emoji}]` : '[sticker]';
|
|
112
|
+
}
|
|
113
|
+
if (msg.location) {
|
|
114
|
+
return `[location: ${msg.location.latitude},${msg.location.longitude}]`;
|
|
115
|
+
}
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
export function formatCallbackContent(query) {
|
|
119
|
+
return query.data ? `[action: ${query.data}]` : '[action]';
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 入站消息 → canonical Segment[];媒体只存在于 canonical 段,不重复写入文本。
|
|
123
|
+
* Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
|
|
124
|
+
* 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
|
|
125
|
+
*/
|
|
126
|
+
export function formatInboundSegments(msg) {
|
|
127
|
+
const out = [];
|
|
128
|
+
if (msg.reply_to_message) {
|
|
129
|
+
out.push({
|
|
130
|
+
type: 'reply',
|
|
131
|
+
data: { message_id: String(msg.reply_to_message.message_id) },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const text = msg.text ?? msg.caption;
|
|
135
|
+
if (text)
|
|
136
|
+
out.push({ type: 'text', data: { text } });
|
|
137
|
+
if (msg.photo?.length) {
|
|
138
|
+
const largest = msg.photo[msg.photo.length - 1];
|
|
139
|
+
out.push({
|
|
140
|
+
type: 'image',
|
|
141
|
+
data: { media: { kind: 'file', value: largest.file_id } },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (msg.video) {
|
|
145
|
+
out.push({
|
|
146
|
+
type: 'video',
|
|
147
|
+
data: { media: { kind: 'file', value: msg.video.file_id } },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (msg.audio) {
|
|
151
|
+
out.push({
|
|
152
|
+
type: 'audio',
|
|
153
|
+
data: {
|
|
154
|
+
media: { kind: 'file', value: msg.audio.file_id },
|
|
155
|
+
...(msg.audio.title ? { name: msg.audio.title } : {}),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (msg.voice) {
|
|
160
|
+
out.push({
|
|
161
|
+
type: 'voice',
|
|
162
|
+
data: { media: { kind: 'file', value: msg.voice.file_id } },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (msg.document) {
|
|
166
|
+
out.push({
|
|
167
|
+
type: 'file',
|
|
168
|
+
data: {
|
|
169
|
+
media: {
|
|
170
|
+
kind: 'file',
|
|
171
|
+
value: msg.document.file_id,
|
|
172
|
+
...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
|
|
173
|
+
},
|
|
174
|
+
...(msg.document.file_name ? { name: msg.document.file_name } : {}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (msg.sticker) {
|
|
179
|
+
out.push({
|
|
180
|
+
type: 'image',
|
|
181
|
+
data: {
|
|
182
|
+
media: { kind: 'file', value: msg.sticker.file_id },
|
|
183
|
+
...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* callback_query → action 段(Wave 1 C interactive 约定:
|
|
191
|
+
* {type:'action', data:{id, payload, sourceMessageId?}}),
|
|
192
|
+
* 与 formatCallbackContent / metadata.payload 同源。
|
|
193
|
+
*/
|
|
194
|
+
export function formatCallbackSegments(query) {
|
|
195
|
+
return [{
|
|
196
|
+
type: 'action',
|
|
197
|
+
data: {
|
|
198
|
+
id: query.id,
|
|
199
|
+
payload: query.data ?? '',
|
|
200
|
+
...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
|
|
201
|
+
},
|
|
202
|
+
}];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
|
|
206
|
+
* Segment canonicalization is intentionally not done here.
|
|
207
|
+
*/
|
|
208
|
+
export function formatOutboundActions(target, payload) {
|
|
209
|
+
return formatOutboundPlan(target, payload).actions;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
|
|
213
|
+
* 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
|
|
214
|
+
* kind=url/file 保持字符串直发(file 即 Telegram file_id 不透明引用)。
|
|
215
|
+
*/
|
|
216
|
+
export function formatOutboundPlan(target, payload) {
|
|
217
|
+
const uploads = [];
|
|
218
|
+
return { actions: buildOutboundActions(target, payload, uploads), uploads };
|
|
219
|
+
}
|
|
220
|
+
function buildOutboundActions(target, payload, uploads) {
|
|
221
|
+
const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
|
|
222
|
+
if (typeof payload === 'string') {
|
|
223
|
+
const text = payload.trim();
|
|
224
|
+
if (!text)
|
|
225
|
+
throw new Error('No Telegram content to send');
|
|
226
|
+
return [{ method: 'sendMessage', params: { chat_id: chatId, text } }];
|
|
227
|
+
}
|
|
228
|
+
const items = Array.isArray(payload)
|
|
229
|
+
? payload
|
|
230
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
231
|
+
? [payload]
|
|
232
|
+
: [];
|
|
233
|
+
const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
|
|
234
|
+
const appendPlain = (value) => hasMarkdown
|
|
235
|
+
? escapeTelegramHtml(String(value ?? ''))
|
|
236
|
+
: String(value ?? '');
|
|
237
|
+
const htmlMode = hasMarkdown ? { parse_mode: 'HTML' } : {};
|
|
238
|
+
if (items.length === 0) {
|
|
239
|
+
const text = payload == null
|
|
240
|
+
? ''
|
|
241
|
+
: typeof payload === 'object'
|
|
242
|
+
? JSON.stringify(payload)
|
|
243
|
+
: String(payload);
|
|
244
|
+
if (!text.trim())
|
|
245
|
+
throw new Error('No Telegram content to send');
|
|
246
|
+
return [{ method: 'sendMessage', params: { chat_id: chatId, text: text.trim() } }];
|
|
247
|
+
}
|
|
248
|
+
let textContent = '';
|
|
249
|
+
let replyTo;
|
|
250
|
+
let keyboard;
|
|
251
|
+
const actions = [];
|
|
252
|
+
const replyParams = () => (replyTo != null ? { reply_parameters: { message_id: replyTo } } : {});
|
|
253
|
+
/**
|
|
254
|
+
* 媒体来源归一:唯一来源是 canonical `data.media` MediaRef。
|
|
255
|
+
* kind=url/file → 字符串直发(file 即 Telegram file_id 不透明引用);
|
|
256
|
+
* kind=base64/path → attach:// 占位并登记上传。
|
|
257
|
+
* 无 MediaRef 时 warn + 丢弃(返回 undefined)。
|
|
258
|
+
*/
|
|
259
|
+
const mediaSource = (segType, data, defaultName) => {
|
|
260
|
+
const media = isMediaRef(data.media) ? data.media : undefined;
|
|
261
|
+
if (!media) {
|
|
262
|
+
logger.warn(formatCompact({
|
|
263
|
+
op: 'telegram_outbound_media_dropped',
|
|
264
|
+
type: segType,
|
|
265
|
+
reason: 'missing_media_ref',
|
|
266
|
+
}));
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
if (media.kind === 'file' || media.kind === 'url')
|
|
270
|
+
return media.value;
|
|
271
|
+
const named = data.name ?? data.filename;
|
|
272
|
+
let filename = typeof named === 'string' && named ? named : undefined;
|
|
273
|
+
if (!filename && media.kind === 'path') {
|
|
274
|
+
const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
275
|
+
filename = raw.split(/[\\/]/).filter(Boolean).pop();
|
|
276
|
+
}
|
|
277
|
+
const attachName = `attach${uploads.length}`;
|
|
278
|
+
uploads.push({
|
|
279
|
+
attachName,
|
|
280
|
+
filename: filename ?? defaultName,
|
|
281
|
+
source: media.kind === 'base64'
|
|
282
|
+
? {
|
|
283
|
+
kind: 'base64',
|
|
284
|
+
data: media.value.startsWith('base64://')
|
|
285
|
+
? media.value.slice('base64://'.length)
|
|
286
|
+
: media.value,
|
|
287
|
+
}
|
|
288
|
+
: {
|
|
289
|
+
kind: 'path',
|
|
290
|
+
path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
|
|
291
|
+
},
|
|
292
|
+
...(media.mime_type ? { mimeType: media.mime_type } : {}),
|
|
293
|
+
});
|
|
294
|
+
return `attach://${attachName}`;
|
|
295
|
+
};
|
|
296
|
+
for (const item of items) {
|
|
297
|
+
if (typeof item === 'string') {
|
|
298
|
+
textContent += appendPlain(item);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const data = item.data ?? {};
|
|
302
|
+
switch (item.type) {
|
|
303
|
+
case 'text':
|
|
304
|
+
textContent += appendPlain(data.text ?? data.content ?? '');
|
|
305
|
+
break;
|
|
306
|
+
case 'markdown':
|
|
307
|
+
textContent += markdownToTelegramHtml(String(data.content ?? data.text ?? ''));
|
|
308
|
+
break;
|
|
309
|
+
case 'at':
|
|
310
|
+
if (data.id)
|
|
311
|
+
textContent += `@${appendPlain(data.name || data.id)}`;
|
|
312
|
+
break;
|
|
313
|
+
case 'reply': {
|
|
314
|
+
const id = Number(data.id ?? data.message_id);
|
|
315
|
+
if (Number.isFinite(id))
|
|
316
|
+
replyTo = id;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'keyboard': {
|
|
320
|
+
const rows = Array.isArray(data.rows) ? data.rows : [];
|
|
321
|
+
keyboard = rows.map((row) => {
|
|
322
|
+
const buttons = Array.isArray(row) ? row : [];
|
|
323
|
+
return buttons.map((btn) => {
|
|
324
|
+
const record = btn && typeof btn === 'object'
|
|
325
|
+
? btn
|
|
326
|
+
: {};
|
|
327
|
+
return {
|
|
328
|
+
text: String(record.label ?? record.text ?? ''),
|
|
329
|
+
callback_data: String(record.payload ?? record.callback_data ?? '').slice(0, 64),
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
case 'image': {
|
|
336
|
+
const photo = mediaSource('image', data, 'image.png');
|
|
337
|
+
if (photo) {
|
|
338
|
+
actions.push({
|
|
339
|
+
method: 'sendPhoto',
|
|
340
|
+
params: {
|
|
341
|
+
chat_id: chatId,
|
|
342
|
+
photo,
|
|
343
|
+
caption: textContent.trim() || undefined,
|
|
344
|
+
...(textContent.trim() ? htmlMode : {}),
|
|
345
|
+
...replyParams(),
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
textContent = '';
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case 'video': {
|
|
353
|
+
const video = mediaSource('video', data, 'video.mp4');
|
|
354
|
+
if (video) {
|
|
355
|
+
actions.push({
|
|
356
|
+
method: 'sendVideo',
|
|
357
|
+
params: {
|
|
358
|
+
chat_id: chatId,
|
|
359
|
+
video,
|
|
360
|
+
caption: textContent.trim() || undefined,
|
|
361
|
+
...(textContent.trim() ? htmlMode : {}),
|
|
362
|
+
...replyParams(),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
textContent = '';
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
case 'audio': {
|
|
370
|
+
const audio = mediaSource('audio', data, 'audio.mp3');
|
|
371
|
+
if (audio) {
|
|
372
|
+
actions.push({
|
|
373
|
+
method: 'sendAudio',
|
|
374
|
+
params: {
|
|
375
|
+
chat_id: chatId,
|
|
376
|
+
audio,
|
|
377
|
+
caption: textContent.trim() || undefined,
|
|
378
|
+
...(textContent.trim() ? htmlMode : {}),
|
|
379
|
+
...replyParams(),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
textContent = '';
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'voice': {
|
|
387
|
+
const voice = mediaSource('voice', data, 'voice.ogg');
|
|
388
|
+
if (voice) {
|
|
389
|
+
actions.push({
|
|
390
|
+
method: 'sendVoice',
|
|
391
|
+
params: {
|
|
392
|
+
chat_id: chatId,
|
|
393
|
+
voice,
|
|
394
|
+
caption: textContent.trim() || undefined,
|
|
395
|
+
...(textContent.trim() ? htmlMode : {}),
|
|
396
|
+
...replyParams(),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
textContent = '';
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case 'file': {
|
|
404
|
+
const document = mediaSource('file', data, 'file');
|
|
405
|
+
if (document) {
|
|
406
|
+
actions.push({
|
|
407
|
+
method: 'sendDocument',
|
|
408
|
+
params: {
|
|
409
|
+
chat_id: chatId,
|
|
410
|
+
document,
|
|
411
|
+
caption: textContent.trim() || undefined,
|
|
412
|
+
...(textContent.trim() ? htmlMode : {}),
|
|
413
|
+
...replyParams(),
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
textContent = '';
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'sticker': {
|
|
421
|
+
const sticker = mediaSource('sticker', data, 'sticker.webp');
|
|
422
|
+
if (sticker) {
|
|
423
|
+
actions.push({
|
|
424
|
+
method: 'sendSticker',
|
|
425
|
+
params: { chat_id: chatId, sticker, ...replyParams() },
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case 'location': {
|
|
431
|
+
actions.push({
|
|
432
|
+
method: 'sendLocation',
|
|
433
|
+
params: {
|
|
434
|
+
chat_id: chatId,
|
|
435
|
+
latitude: Number(data.latitude ?? 0),
|
|
436
|
+
longitude: Number(data.longitude ?? 0),
|
|
437
|
+
...replyParams(),
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
default:
|
|
443
|
+
textContent += appendPlain(data.text ?? `[${item.type}]`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (actions.length === 0) {
|
|
447
|
+
const text = textContent.trim() || (keyboard ? ' ' : '');
|
|
448
|
+
if (!text && !keyboard)
|
|
449
|
+
throw new Error('No Telegram content to send');
|
|
450
|
+
return [{
|
|
451
|
+
method: 'sendMessage',
|
|
452
|
+
params: {
|
|
453
|
+
chat_id: chatId,
|
|
454
|
+
text: text || ' ',
|
|
455
|
+
...htmlMode,
|
|
456
|
+
...replyParams(),
|
|
457
|
+
...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
|
|
458
|
+
},
|
|
459
|
+
}];
|
|
460
|
+
}
|
|
461
|
+
if (textContent.trim() || keyboard) {
|
|
462
|
+
actions.unshift({
|
|
463
|
+
method: 'sendMessage',
|
|
464
|
+
params: {
|
|
465
|
+
chat_id: chatId,
|
|
466
|
+
text: textContent.trim() || ' ',
|
|
467
|
+
...htmlMode,
|
|
468
|
+
...replyParams(),
|
|
469
|
+
...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return actions;
|
|
474
|
+
}
|
|
@@ -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, string | undefined>>>;
|
|
@@ -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").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
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
3
|
+
import { type ResolvedTelegramConfig, type TelegramUpdate } from './protocol.js';
|
|
4
|
+
/** 等长时才 timingSafeEqual,避免长度差异直接抛异常。 */
|
|
5
|
+
export declare function safeTokenEqual(a: string, b: string): boolean;
|
|
6
|
+
export interface TelegramWebhookHandler {
|
|
7
|
+
readonly config: ResolvedTelegramConfig;
|
|
8
|
+
readonly isOpen: boolean;
|
|
9
|
+
handleUpdate(update: TelegramUpdate): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function registerTelegramWebhookRoutes(http: HttpHost, handler: TelegramWebhookHandler): HttpRouteRegistration[];
|
|
12
|
+
export declare function handleTelegramWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: TelegramWebhookHandler): Promise<void>;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram webhook HTTP: secret token → parse → handle update.
|
|
3
|
+
*/
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { getLogger } from '@zhin.js/logger';
|
|
6
|
+
import { readTextBody } from './protocol.js';
|
|
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
|
+
}
|
|
14
|
+
export function registerTelegramWebhookRoutes(http, handler) {
|
|
15
|
+
const path = handler.config.webhook.path;
|
|
16
|
+
return [
|
|
17
|
+
http.route('POST', path, async (request, response) => {
|
|
18
|
+
await handleTelegramWebhookRequest(request, response, handler);
|
|
19
|
+
}, { summary: 'Telegram Bot API webhook', tags: ['telegram'] }),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export async function handleTelegramWebhookRequest(request, response, handler) {
|
|
23
|
+
try {
|
|
24
|
+
const secret = handler.config.webhook?.secretToken;
|
|
25
|
+
if (secret) {
|
|
26
|
+
const header = request.headers['x-telegram-bot-api-secret-token'];
|
|
27
|
+
const token = Array.isArray(header) ? header[0] : header;
|
|
28
|
+
if (!token || !safeTokenEqual(token, secret)) {
|
|
29
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
30
|
+
response.end(JSON.stringify({ ok: false, description: 'Invalid secret token' }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const rawBody = await readTextBody(request);
|
|
35
|
+
let update;
|
|
36
|
+
try {
|
|
37
|
+
update = JSON.parse(rawBody);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
41
|
+
response.end(JSON.stringify({ ok: true }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (handler.isOpen) {
|
|
45
|
+
handler.handleUpdate(update);
|
|
46
|
+
}
|
|
47
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
48
|
+
response.end(JSON.stringify({ ok: true }));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
logger.error('Telegram webhook error:', error);
|
|
52
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
53
|
+
response.end(JSON.stringify({ ok: true }));
|
|
54
|
+
}
|
|
55
|
+
}
|