@zhin.js/adapter-telegram 1.0.68 → 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.
Files changed (78) hide show
  1. package/CHANGELOG.md +825 -16
  2. package/README.md +106 -77
  3. package/adapters/telegram.js +34 -0
  4. package/adapters/telegram.ts +39 -0
  5. package/agent/PERMITS.md +24 -0
  6. package/agent/tools/create_invite.ts +18 -0
  7. package/agent/tools/list_admins.ts +24 -0
  8. package/agent/tools/member_count.ts +16 -0
  9. package/agent/tools/pin_message.ts +19 -0
  10. package/agent/tools/react.ts +18 -0
  11. package/agent/tools/send_poll.ts +32 -0
  12. package/agent/tools/send_sticker.ts +17 -0
  13. package/agent/tools/set_description.ts +17 -0
  14. package/agent/tools/set_permissions.ts +31 -0
  15. package/agent/tools/unpin_message.ts +19 -0
  16. package/commands/endpoint/add/[id].js +3 -0
  17. package/commands/endpoint/add/[id].ts +3 -0
  18. package/commands/endpoint/list.js +3 -0
  19. package/commands/endpoint/list.ts +3 -0
  20. package/commands/endpoint/remove/[id].js +3 -0
  21. package/commands/endpoint/remove/[id].ts +3 -0
  22. package/lib/client.d.ts +12 -0
  23. package/lib/client.js +2 -0
  24. package/lib/endpoint.d.ts +112 -0
  25. package/lib/endpoint.js +535 -0
  26. package/lib/index.d.ts +4 -18
  27. package/lib/index.js +4 -455
  28. package/lib/markdown-to-html.d.ts +9 -0
  29. package/lib/markdown-to-html.js +75 -0
  30. package/lib/platform-permit.d.ts +17 -0
  31. package/lib/platform-permit.js +51 -0
  32. package/lib/polling.d.ts +9 -0
  33. package/lib/polling.js +57 -0
  34. package/lib/protocol.d.ts +299 -0
  35. package/lib/protocol.js +474 -0
  36. package/lib/telegram-endpoint-commands.d.ts +1 -0
  37. package/lib/telegram-endpoint-commands.js +16 -0
  38. package/lib/telegram-runtime-state.d.ts +1 -0
  39. package/lib/telegram-runtime-state.js +6 -0
  40. package/lib/webhook.d.ts +12 -0
  41. package/lib/webhook.js +55 -0
  42. package/package.json +59 -28
  43. package/plugin.js +19 -0
  44. package/schema.json +110 -0
  45. package/src/client.ts +16 -0
  46. package/src/endpoint.ts +679 -0
  47. package/src/index.ts +39 -426
  48. package/src/markdown-to-html.ts +86 -0
  49. package/src/platform-permit.ts +65 -0
  50. package/src/polling.ts +76 -0
  51. package/src/protocol.ts +767 -0
  52. package/src/telegram-endpoint-commands.ts +17 -0
  53. package/src/telegram-runtime-state.ts +7 -0
  54. package/src/webhook.ts +75 -0
  55. package/client/Dashboard.tsx +0 -295
  56. package/client/index.tsx +0 -11
  57. package/client/tsconfig.json +0 -7
  58. package/client/utils/api.ts +0 -17
  59. package/dist/index.js +0 -32
  60. package/lib/adapter.d.ts +0 -18
  61. package/lib/adapter.d.ts.map +0 -1
  62. package/lib/adapter.js +0 -55
  63. package/lib/adapter.js.map +0 -1
  64. package/lib/bot.d.ts +0 -140
  65. package/lib/bot.d.ts.map +0 -1
  66. package/lib/bot.js +0 -866
  67. package/lib/bot.js.map +0 -1
  68. package/lib/index.d.ts.map +0 -1
  69. package/lib/index.js.map +0 -1
  70. package/lib/types.d.ts +0 -29
  71. package/lib/types.d.ts.map +0 -1
  72. package/lib/types.js +0 -2
  73. package/lib/types.js.map +0 -1
  74. package/plugin.yml +0 -3
  75. package/src/adapter.ts +0 -64
  76. package/src/bot.ts +0 -983
  77. package/src/types.ts +0 -32
  78. /package/{skills/telegram/SKILL.md → agent/skills/telegram.md} +0 -0
@@ -0,0 +1,767 @@
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
+
6
+ import type { IncomingMessage } from 'node:http';
7
+ import { isMediaRef } from '@zhin.js/core';
8
+ import type { Segment } from '@zhin.js/core/runtime';
9
+ import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
10
+ import { formatCompact, getLogger } from '@zhin.js/logger';
11
+ import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
12
+
13
+ const logger = getLogger('telegram');
14
+
15
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
16
+ export interface TelegramAdapterConfig {
17
+ readonly id?: string;
18
+ readonly token?: string;
19
+ /** Default true. `false` selects webhook mode (requires httpHostToken). */
20
+ readonly polling?: boolean;
21
+ readonly webhook?: {
22
+ readonly domain?: string;
23
+ readonly path?: string;
24
+ readonly secretToken?: string;
25
+ };
26
+ readonly allowedUpdates?: readonly string[];
27
+ readonly apiBaseUrl?: string;
28
+ /** Transitional: legacy root `endpoints[]` with `context: telegram`. */
29
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedTelegramConfig> & {
30
+ readonly context?: string;
31
+ readonly polling?: boolean;
32
+ readonly webhook?: TelegramAdapterConfig['webhook'];
33
+ readonly allowedUpdates?: readonly string[];
34
+ readonly apiBaseUrl?: string;
35
+ }>;
36
+ }
37
+
38
+ export interface ResolvedTelegramConfig {
39
+ readonly context: 'telegram';
40
+ readonly id: string;
41
+ readonly token: string;
42
+ readonly mode: 'polling' | 'webhook';
43
+ readonly allowedUpdates: readonly string[];
44
+ readonly apiBaseUrl: string;
45
+ readonly webhook?: {
46
+ readonly domain: string;
47
+ readonly path: string;
48
+ readonly secretToken?: string;
49
+ };
50
+ }
51
+
52
+ export interface TelegramUser {
53
+ readonly id: number;
54
+ readonly is_bot?: boolean;
55
+ readonly first_name?: string;
56
+ readonly last_name?: string;
57
+ readonly username?: string;
58
+ }
59
+
60
+ export interface TelegramChat {
61
+ readonly id: number;
62
+ readonly type: 'private' | 'group' | 'supergroup' | 'channel';
63
+ readonly title?: string;
64
+ readonly username?: string;
65
+ }
66
+
67
+ export interface TelegramMessageEntity {
68
+ readonly type: string;
69
+ readonly offset: number;
70
+ readonly length: number;
71
+ readonly url?: string;
72
+ readonly user?: TelegramUser;
73
+ }
74
+
75
+ export interface TelegramPhotoSize {
76
+ readonly file_id: string;
77
+ readonly file_unique_id?: string;
78
+ readonly width?: number;
79
+ readonly height?: number;
80
+ readonly file_size?: number;
81
+ }
82
+
83
+ export interface TelegramMessage {
84
+ readonly message_id: number;
85
+ readonly date: number;
86
+ readonly chat: TelegramChat;
87
+ readonly from?: TelegramUser;
88
+ readonly text?: string;
89
+ readonly caption?: string;
90
+ readonly entities?: readonly TelegramMessageEntity[];
91
+ readonly reply_to_message?: TelegramMessage;
92
+ readonly photo?: readonly TelegramPhotoSize[];
93
+ readonly video?: {
94
+ readonly file_id: string;
95
+ readonly file_unique_id?: string;
96
+ readonly width?: number;
97
+ readonly height?: number;
98
+ readonly duration?: number;
99
+ readonly file_size?: number;
100
+ };
101
+ readonly audio?: {
102
+ readonly file_id: string;
103
+ readonly file_unique_id?: string;
104
+ readonly duration?: number;
105
+ readonly performer?: string;
106
+ readonly title?: string;
107
+ readonly file_size?: number;
108
+ };
109
+ readonly voice?: {
110
+ readonly file_id: string;
111
+ readonly file_unique_id?: string;
112
+ readonly duration?: number;
113
+ readonly file_size?: number;
114
+ };
115
+ readonly document?: {
116
+ readonly file_id: string;
117
+ readonly file_unique_id?: string;
118
+ readonly file_name?: string;
119
+ readonly mime_type?: string;
120
+ readonly file_size?: number;
121
+ };
122
+ readonly sticker?: {
123
+ readonly file_id: string;
124
+ readonly file_unique_id?: string;
125
+ readonly width?: number;
126
+ readonly height?: number;
127
+ readonly is_animated?: boolean;
128
+ readonly is_video?: boolean;
129
+ readonly emoji?: string;
130
+ };
131
+ readonly location?: {
132
+ readonly longitude: number;
133
+ readonly latitude: number;
134
+ };
135
+ }
136
+
137
+ export interface TelegramCallbackQuery {
138
+ readonly id: string;
139
+ readonly from: TelegramUser;
140
+ readonly data?: string;
141
+ readonly message?: TelegramMessage;
142
+ }
143
+
144
+ export interface TelegramUpdate {
145
+ readonly update_id: number;
146
+ readonly message?: TelegramMessage;
147
+ readonly edited_message?: TelegramMessage;
148
+ readonly callback_query?: TelegramCallbackQuery;
149
+ }
150
+
151
+ export interface TelegramChatMember {
152
+ readonly status: string;
153
+ readonly user: TelegramUser;
154
+ readonly can_restrict_members?: boolean;
155
+ readonly can_pin_messages?: boolean;
156
+ readonly can_delete_messages?: boolean;
157
+ readonly can_manage_chat?: boolean;
158
+ }
159
+
160
+ export interface TelegramWireSegment {
161
+ readonly type: string;
162
+ readonly data?: Record<string, unknown>;
163
+ }
164
+
165
+ export interface TelegramInlineButton {
166
+ readonly text: string;
167
+ readonly callback_data: string;
168
+ }
169
+
170
+ export type TelegramOutboundAction =
171
+ | {
172
+ readonly method: 'sendMessage';
173
+ readonly params: {
174
+ readonly chat_id: number | string;
175
+ readonly text: string;
176
+ readonly parse_mode?: 'HTML';
177
+ readonly reply_parameters?: { readonly message_id: number };
178
+ readonly reply_markup?: { readonly inline_keyboard: TelegramInlineButton[][] };
179
+ };
180
+ }
181
+ | {
182
+ readonly method: 'sendPhoto';
183
+ readonly params: {
184
+ readonly chat_id: number | string;
185
+ readonly photo: string;
186
+ readonly caption?: string;
187
+ readonly parse_mode?: 'HTML';
188
+ readonly reply_parameters?: { readonly message_id: number };
189
+ };
190
+ }
191
+ | {
192
+ readonly method: 'sendVideo';
193
+ readonly params: {
194
+ readonly chat_id: number | string;
195
+ readonly video: string;
196
+ readonly caption?: string;
197
+ readonly parse_mode?: 'HTML';
198
+ readonly reply_parameters?: { readonly message_id: number };
199
+ };
200
+ }
201
+ | {
202
+ readonly method: 'sendAudio';
203
+ readonly params: {
204
+ readonly chat_id: number | string;
205
+ readonly audio: string;
206
+ readonly caption?: string;
207
+ readonly parse_mode?: 'HTML';
208
+ readonly reply_parameters?: { readonly message_id: number };
209
+ };
210
+ }
211
+ | {
212
+ readonly method: 'sendVoice';
213
+ readonly params: {
214
+ readonly chat_id: number | string;
215
+ readonly voice: string;
216
+ readonly caption?: string;
217
+ readonly parse_mode?: 'HTML';
218
+ readonly reply_parameters?: { readonly message_id: number };
219
+ };
220
+ }
221
+ | {
222
+ readonly method: 'sendDocument';
223
+ readonly params: {
224
+ readonly chat_id: number | string;
225
+ readonly document: string;
226
+ readonly caption?: string;
227
+ readonly parse_mode?: 'HTML';
228
+ readonly reply_parameters?: { readonly message_id: number };
229
+ };
230
+ }
231
+ | {
232
+ readonly method: 'sendSticker';
233
+ readonly params: {
234
+ readonly chat_id: number | string;
235
+ readonly sticker: string;
236
+ readonly reply_parameters?: { readonly message_id: number };
237
+ };
238
+ }
239
+ | {
240
+ readonly method: 'sendLocation';
241
+ readonly params: {
242
+ readonly chat_id: number | string;
243
+ readonly latitude: number;
244
+ readonly longitude: number;
245
+ readonly reply_parameters?: { readonly message_id: number };
246
+ };
247
+ };
248
+
249
+ export function resolveTelegramConfig(config: TelegramAdapterConfig = {}): ResolvedTelegramConfig {
250
+ const entry = config.endpoints?.find((item) => item.context === 'telegram');
251
+ const token = config.token
252
+ ?? entry?.token
253
+ ?? process.env.TELEGRAM_TOKEN
254
+ ?? process.env.TELEGRAM_BOT_TOKEN;
255
+ if (!token) {
256
+ throw new TypeError(
257
+ 'Telegram adapter requires token (plugins.<key>.token or endpoints with context: telegram)',
258
+ );
259
+ }
260
+ const id = (typeof config.id === 'string' && config.id)
261
+ || (typeof entry?.id === 'string' && entry.id)
262
+ || process.env.TELEGRAM_BOT_NAME
263
+ || 'telegram-bot';
264
+ const polling = config.polling ?? entry?.polling;
265
+ const webhookSource = config.webhook ?? entry?.webhook;
266
+ // Match legacy: polling defaults true; webhook only when polling === false.
267
+ const mode: 'polling' | 'webhook' = polling === false ? 'webhook' : 'polling';
268
+ const apiBaseUrl = (
269
+ config.apiBaseUrl
270
+ ?? entry?.apiBaseUrl
271
+ ?? 'https://api.telegram.org'
272
+ ).replace(/\/$/, '');
273
+ const allowedUpdates = config.allowedUpdates
274
+ ?? entry?.allowedUpdates
275
+ ?? ['message', 'callback_query'];
276
+ const webhook = mode === 'webhook'
277
+ ? {
278
+ domain: webhookSource?.domain ?? '',
279
+ path: normalizeWebhookPath(webhookSource?.path ?? '/telegram/webhook'),
280
+ secretToken: webhookSource?.secretToken
281
+ ?? process.env.TELEGRAM_WEBHOOK_SECRET
282
+ ?? undefined,
283
+ }
284
+ : undefined;
285
+ return {
286
+ context: 'telegram',
287
+ id,
288
+ token,
289
+ mode,
290
+ allowedUpdates: [...allowedUpdates],
291
+ apiBaseUrl,
292
+ webhook,
293
+ };
294
+ }
295
+
296
+ export function normalizeWebhookPath(path: string): string {
297
+ const trimmed = path.trim() || '/telegram/webhook';
298
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
299
+ }
300
+
301
+ export function buildWebhookUrl(webhook: NonNullable<ResolvedTelegramConfig['webhook']>): string {
302
+ const domain = webhook.domain.replace(/\/$/, '');
303
+ if (!domain) {
304
+ throw new TypeError('Telegram webhook mode requires webhook.domain');
305
+ }
306
+ return `${domain}${webhook.path}`;
307
+ }
308
+
309
+ export async function readTextBody(
310
+ request: IncomingMessage,
311
+ options: { readonly limit?: number } = {},
312
+ ): Promise<string> {
313
+ const limit = options.limit ?? 1_048_576;
314
+ const chunks: Buffer[] = [];
315
+ let size = 0;
316
+ for await (const chunk of request) {
317
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
318
+ size += buffer.length;
319
+ if (size > limit) {
320
+ request.destroy();
321
+ throw new Error(`Request body exceeds ${limit} bytes`);
322
+ }
323
+ chunks.push(buffer);
324
+ }
325
+ return Buffer.concat(chunks).toString('utf8');
326
+ }
327
+
328
+ export function botApiUrl(config: Pick<ResolvedTelegramConfig, 'apiBaseUrl' | 'token'>, method: string): string {
329
+ return `${config.apiBaseUrl}/bot${config.token}/${method}`;
330
+ }
331
+
332
+ /** Telegram chat.type → canonical 会话 kind(supergroup 即 group,无容器层级故无 parent)。 */
333
+ export function resolveTelegramChannelType(
334
+ chatType: TelegramChat['type'],
335
+ ): ConversationKind {
336
+ if (chatType === 'private') return 'private';
337
+ if (chatType === 'channel') return 'channel';
338
+ return 'group';
339
+ }
340
+
341
+ /**
342
+ * 入站归一化 → ConversationRef:Telegram 无 guild/群组容器层级,
343
+ * kind 直取 chat.type(private/group/supergroup/channel),无 parent。
344
+ */
345
+ export function telegramInboundConversation(
346
+ endpointKey: string,
347
+ chat: Pick<TelegramChat, 'id' | 'type'>,
348
+ ): ConversationRef {
349
+ return {
350
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
351
+ kind: resolveTelegramChannelType(chat.type),
352
+ id: String(chat.id),
353
+ };
354
+ }
355
+
356
+ export function senderDisplayName(user?: TelegramUser): string {
357
+ if (!user) return 'Unknown';
358
+ return user.username || user.first_name || String(user.id);
359
+ }
360
+
361
+ /** Build inbound text for OutboundMessageService.receive. */
362
+ export function formatInboundContent(msg: TelegramMessage): string {
363
+ if (msg.text) return msg.text;
364
+ if (msg.caption) return msg.caption;
365
+ if (msg.sticker) {
366
+ return msg.sticker.emoji ? `[sticker: ${msg.sticker.emoji}]` : '[sticker]';
367
+ }
368
+ if (msg.location) {
369
+ return `[location: ${msg.location.latitude},${msg.location.longitude}]`;
370
+ }
371
+ return '';
372
+ }
373
+
374
+ export function formatCallbackContent(query: TelegramCallbackQuery): string {
375
+ return query.data ? `[action: ${query.data}]` : '[action]';
376
+ }
377
+
378
+ /**
379
+ * 入站消息 → canonical Segment[];媒体只存在于 canonical 段,不重复写入文本。
380
+ * Telegram 附件只有不透明 file_id(需 getFile 二次解析,非 URL),
381
+ * 统一进 MediaRef kind=file;photo 取数组末尾(最大尺寸)。
382
+ */
383
+ export function formatInboundSegments(msg: TelegramMessage): Segment[] {
384
+ const out: Segment[] = [];
385
+ if (msg.reply_to_message) {
386
+ out.push({
387
+ type: 'reply',
388
+ data: { message_id: String(msg.reply_to_message.message_id) },
389
+ });
390
+ }
391
+ const text = msg.text ?? msg.caption;
392
+ if (text) out.push({ type: 'text', data: { text } });
393
+ if (msg.photo?.length) {
394
+ const largest = msg.photo[msg.photo.length - 1]!;
395
+ out.push({
396
+ type: 'image',
397
+ data: { media: { kind: 'file', value: largest.file_id } },
398
+ });
399
+ }
400
+ if (msg.video) {
401
+ out.push({
402
+ type: 'video',
403
+ data: { media: { kind: 'file', value: msg.video.file_id } },
404
+ });
405
+ }
406
+ if (msg.audio) {
407
+ out.push({
408
+ type: 'audio',
409
+ data: {
410
+ media: { kind: 'file', value: msg.audio.file_id },
411
+ ...(msg.audio.title ? { name: msg.audio.title } : {}),
412
+ },
413
+ });
414
+ }
415
+ if (msg.voice) {
416
+ out.push({
417
+ type: 'voice',
418
+ data: { media: { kind: 'file', value: msg.voice.file_id } },
419
+ });
420
+ }
421
+ if (msg.document) {
422
+ out.push({
423
+ type: 'file',
424
+ data: {
425
+ media: {
426
+ kind: 'file',
427
+ value: msg.document.file_id,
428
+ ...(msg.document.mime_type ? { mime_type: msg.document.mime_type } : {}),
429
+ },
430
+ ...(msg.document.file_name ? { name: msg.document.file_name } : {}),
431
+ },
432
+ });
433
+ }
434
+ if (msg.sticker) {
435
+ out.push({
436
+ type: 'image',
437
+ data: {
438
+ media: { kind: 'file', value: msg.sticker.file_id },
439
+ ...(msg.sticker.emoji ? { alt: msg.sticker.emoji } : {}),
440
+ },
441
+ });
442
+ }
443
+ return out;
444
+ }
445
+
446
+ /**
447
+ * callback_query → action 段(Wave 1 C interactive 约定:
448
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
449
+ * 与 formatCallbackContent / metadata.payload 同源。
450
+ */
451
+ export function formatCallbackSegments(query: TelegramCallbackQuery): Segment[] {
452
+ return [{
453
+ type: 'action',
454
+ data: {
455
+ id: query.id,
456
+ payload: query.data ?? '',
457
+ ...(query.message ? { sourceMessageId: String(query.message.message_id) } : {}),
458
+ },
459
+ }];
460
+ }
461
+
462
+ /**
463
+ * 出站待上传媒体(base64 / 本地路径 MediaRef 物化为 multipart 附件)。
464
+ * params 里以 `attach://<attachName>` 占位,endpoint 发送时替换为文件 part。
465
+ */
466
+ export interface TelegramOutboundUpload {
467
+ readonly attachName: string;
468
+ readonly filename: string;
469
+ readonly source:
470
+ | { readonly kind: 'base64'; readonly data: string }
471
+ | { readonly kind: 'path'; readonly path: string };
472
+ readonly mimeType?: string;
473
+ }
474
+
475
+ export interface TelegramOutboundPlan {
476
+ readonly actions: TelegramOutboundAction[];
477
+ readonly uploads: readonly TelegramOutboundUpload[];
478
+ }
479
+
480
+ /**
481
+ * Wire-encode an already-rendered outbound payload into Telegram Bot API actions.
482
+ * Segment canonicalization is intentionally not done here.
483
+ */
484
+ export function formatOutboundActions(
485
+ target: string | number,
486
+ payload: unknown,
487
+ ): TelegramOutboundAction[] {
488
+ return formatOutboundPlan(target, payload).actions;
489
+ }
490
+
491
+ /**
492
+ * formatOutboundActions 的上传感知变体:canonical MediaRef kind=base64/path
493
+ * 的媒体段产出 `attach://` 占位 + uploads 清单(endpoint 走 multipart 表单上传);
494
+ * kind=url/file 保持字符串直发(file 即 Telegram file_id 不透明引用)。
495
+ */
496
+ export function formatOutboundPlan(
497
+ target: string | number,
498
+ payload: unknown,
499
+ ): TelegramOutboundPlan {
500
+ const uploads: TelegramOutboundUpload[] = [];
501
+ return { actions: buildOutboundActions(target, payload, uploads), uploads };
502
+ }
503
+
504
+ function buildOutboundActions(
505
+ target: string | number,
506
+ payload: unknown,
507
+ uploads: TelegramOutboundUpload[],
508
+ ): TelegramOutboundAction[] {
509
+ const chatId = typeof target === 'number' ? target : (/^-?\d+$/.test(target) ? Number(target) : target);
510
+ if (typeof payload === 'string') {
511
+ const text = payload.trim();
512
+ if (!text) throw new Error('No Telegram content to send');
513
+ return [{ method: 'sendMessage', params: { chat_id: chatId, text } }];
514
+ }
515
+
516
+ const items: Array<string | TelegramWireSegment> = Array.isArray(payload)
517
+ ? payload as Array<string | TelegramWireSegment>
518
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
519
+ ? [payload as TelegramWireSegment]
520
+ : [];
521
+ const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
522
+ const appendPlain = (value: unknown): string => hasMarkdown
523
+ ? escapeTelegramHtml(String(value ?? ''))
524
+ : String(value ?? '');
525
+ const htmlMode = hasMarkdown ? { parse_mode: 'HTML' as const } : {};
526
+
527
+ if (items.length === 0) {
528
+ const text = payload == null
529
+ ? ''
530
+ : typeof payload === 'object'
531
+ ? JSON.stringify(payload)
532
+ : String(payload);
533
+ if (!text.trim()) throw new Error('No Telegram content to send');
534
+ return [{ method: 'sendMessage', params: { chat_id: chatId, text: text.trim() } }];
535
+ }
536
+
537
+ let textContent = '';
538
+ let replyTo: number | undefined;
539
+ let keyboard: TelegramInlineButton[][] | undefined;
540
+ const actions: TelegramOutboundAction[] = [];
541
+
542
+ const replyParams = (): { reply_parameters?: { message_id: number } } => (
543
+ replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}
544
+ );
545
+
546
+ /**
547
+ * 媒体来源归一:唯一来源是 canonical `data.media` MediaRef。
548
+ * kind=url/file → 字符串直发(file 即 Telegram file_id 不透明引用);
549
+ * kind=base64/path → attach:// 占位并登记上传。
550
+ * 无 MediaRef 时 warn + 丢弃(返回 undefined)。
551
+ */
552
+ const mediaSource = (segType: string, data: Record<string, unknown>, defaultName: string): string | undefined => {
553
+ const media = isMediaRef(data.media) ? data.media : undefined;
554
+ if (!media) {
555
+ logger.warn(formatCompact({
556
+ op: 'telegram_outbound_media_dropped',
557
+ type: segType,
558
+ reason: 'missing_media_ref',
559
+ }));
560
+ return undefined;
561
+ }
562
+ if (media.kind === 'file' || media.kind === 'url') return media.value;
563
+ const named = data.name ?? data.filename;
564
+ let filename = typeof named === 'string' && named ? named : undefined;
565
+ if (!filename && media.kind === 'path') {
566
+ const raw = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
567
+ filename = raw.split(/[\\/]/).filter(Boolean).pop();
568
+ }
569
+ const attachName = `attach${uploads.length}`;
570
+ uploads.push({
571
+ attachName,
572
+ filename: filename ?? defaultName,
573
+ source: media.kind === 'base64'
574
+ ? {
575
+ kind: 'base64',
576
+ data: media.value.startsWith('base64://')
577
+ ? media.value.slice('base64://'.length)
578
+ : media.value,
579
+ }
580
+ : {
581
+ kind: 'path',
582
+ path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value,
583
+ },
584
+ ...(media.mime_type ? { mimeType: media.mime_type } : {}),
585
+ });
586
+ return `attach://${attachName}`;
587
+ };
588
+
589
+ for (const item of items) {
590
+ if (typeof item === 'string') {
591
+ textContent += appendPlain(item);
592
+ continue;
593
+ }
594
+ const data = item.data ?? {};
595
+ switch (item.type) {
596
+ case 'text':
597
+ textContent += appendPlain(data.text ?? data.content ?? '');
598
+ break;
599
+ case 'markdown':
600
+ textContent += markdownToTelegramHtml(String(data.content ?? data.text ?? ''));
601
+ break;
602
+ case 'at':
603
+ if (data.id) textContent += `@${appendPlain(data.name || data.id)}`;
604
+ break;
605
+ case 'reply': {
606
+ const id = Number(data.id ?? data.message_id);
607
+ if (Number.isFinite(id)) replyTo = id;
608
+ break;
609
+ }
610
+ case 'keyboard': {
611
+ const rows = Array.isArray(data.rows) ? data.rows : [];
612
+ keyboard = rows.map((row) => {
613
+ const buttons = Array.isArray(row) ? row : [];
614
+ return buttons.map((btn) => {
615
+ const record = btn && typeof btn === 'object'
616
+ ? btn as { label?: string; text?: string; payload?: string; callback_data?: string }
617
+ : {};
618
+ return {
619
+ text: String(record.label ?? record.text ?? ''),
620
+ callback_data: String(record.payload ?? record.callback_data ?? '').slice(0, 64),
621
+ };
622
+ });
623
+ });
624
+ break;
625
+ }
626
+ case 'image': {
627
+ const photo = mediaSource('image', data, 'image.png');
628
+ if (photo) {
629
+ actions.push({
630
+ method: 'sendPhoto',
631
+ params: {
632
+ chat_id: chatId,
633
+ photo,
634
+ caption: textContent.trim() || undefined,
635
+ ...(textContent.trim() ? htmlMode : {}),
636
+ ...replyParams(),
637
+ },
638
+ });
639
+ textContent = '';
640
+ }
641
+ break;
642
+ }
643
+ case 'video': {
644
+ const video = mediaSource('video', data, 'video.mp4');
645
+ if (video) {
646
+ actions.push({
647
+ method: 'sendVideo',
648
+ params: {
649
+ chat_id: chatId,
650
+ video,
651
+ caption: textContent.trim() || undefined,
652
+ ...(textContent.trim() ? htmlMode : {}),
653
+ ...replyParams(),
654
+ },
655
+ });
656
+ textContent = '';
657
+ }
658
+ break;
659
+ }
660
+ case 'audio': {
661
+ const audio = mediaSource('audio', data, 'audio.mp3');
662
+ if (audio) {
663
+ actions.push({
664
+ method: 'sendAudio',
665
+ params: {
666
+ chat_id: chatId,
667
+ audio,
668
+ caption: textContent.trim() || undefined,
669
+ ...(textContent.trim() ? htmlMode : {}),
670
+ ...replyParams(),
671
+ },
672
+ });
673
+ textContent = '';
674
+ }
675
+ break;
676
+ }
677
+ case 'voice': {
678
+ const voice = mediaSource('voice', data, 'voice.ogg');
679
+ if (voice) {
680
+ actions.push({
681
+ method: 'sendVoice',
682
+ params: {
683
+ chat_id: chatId,
684
+ voice,
685
+ caption: textContent.trim() || undefined,
686
+ ...(textContent.trim() ? htmlMode : {}),
687
+ ...replyParams(),
688
+ },
689
+ });
690
+ textContent = '';
691
+ }
692
+ break;
693
+ }
694
+ case 'file': {
695
+ const document = mediaSource('file', data, 'file');
696
+ if (document) {
697
+ actions.push({
698
+ method: 'sendDocument',
699
+ params: {
700
+ chat_id: chatId,
701
+ document,
702
+ caption: textContent.trim() || undefined,
703
+ ...(textContent.trim() ? htmlMode : {}),
704
+ ...replyParams(),
705
+ },
706
+ });
707
+ textContent = '';
708
+ }
709
+ break;
710
+ }
711
+ case 'sticker': {
712
+ const sticker = mediaSource('sticker', data, 'sticker.webp');
713
+ if (sticker) {
714
+ actions.push({
715
+ method: 'sendSticker',
716
+ params: { chat_id: chatId, sticker, ...replyParams() },
717
+ });
718
+ }
719
+ break;
720
+ }
721
+ case 'location': {
722
+ actions.push({
723
+ method: 'sendLocation',
724
+ params: {
725
+ chat_id: chatId,
726
+ latitude: Number(data.latitude ?? 0),
727
+ longitude: Number(data.longitude ?? 0),
728
+ ...replyParams(),
729
+ },
730
+ });
731
+ break;
732
+ }
733
+ default:
734
+ textContent += appendPlain(data.text ?? `[${item.type}]`);
735
+ }
736
+ }
737
+
738
+ if (actions.length === 0) {
739
+ const text = textContent.trim() || (keyboard ? ' ' : '');
740
+ if (!text && !keyboard) throw new Error('No Telegram content to send');
741
+ return [{
742
+ method: 'sendMessage',
743
+ params: {
744
+ chat_id: chatId,
745
+ text: text || ' ',
746
+ ...htmlMode,
747
+ ...replyParams(),
748
+ ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
749
+ },
750
+ }];
751
+ }
752
+
753
+ if (textContent.trim() || keyboard) {
754
+ actions.unshift({
755
+ method: 'sendMessage',
756
+ params: {
757
+ chat_id: chatId,
758
+ text: textContent.trim() || ' ',
759
+ ...htmlMode,
760
+ ...replyParams(),
761
+ ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
762
+ },
763
+ });
764
+ }
765
+
766
+ return actions;
767
+ }