@clowder-ai/connector-telegram 0.0.0-stage → 0.1.0-alpha.7

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 (70) hide show
  1. package/README.en.md +48 -0
  2. package/README.md +24 -2
  3. package/assets/icon.svg +4 -0
  4. package/dist/TelegramAdapter.d.ts +229 -0
  5. package/dist/TelegramAdapter.d.ts.map +1 -0
  6. package/dist/TelegramAdapter.js +923 -0
  7. package/dist/TelegramAdapter.js.map +1 -0
  8. package/dist/command-ingress.d.ts +10 -0
  9. package/dist/command-ingress.d.ts.map +1 -0
  10. package/dist/command-ingress.js +13 -0
  11. package/dist/command-ingress.js.map +1 -0
  12. package/dist/execution-status.d.ts +27 -0
  13. package/dist/execution-status.d.ts.map +1 -0
  14. package/dist/execution-status.js +48 -0
  15. package/dist/execution-status.js.map +1 -0
  16. package/dist/inbound-download.d.ts +13 -0
  17. package/dist/inbound-download.d.ts.map +1 -0
  18. package/dist/inbound-download.js +70 -0
  19. package/dist/inbound-download.js.map +1 -0
  20. package/dist/inbound-media-source.d.ts +25 -0
  21. package/dist/inbound-media-source.d.ts.map +1 -0
  22. package/dist/inbound-media-source.js +111 -0
  23. package/dist/inbound-media-source.js.map +1 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/lifecycle-action.d.ts +30 -0
  29. package/dist/lifecycle-action.d.ts.map +1 -0
  30. package/dist/lifecycle-action.js +457 -0
  31. package/dist/lifecycle-action.js.map +1 -0
  32. package/dist/materialize-media.d.ts +7 -0
  33. package/dist/materialize-media.d.ts.map +1 -0
  34. package/dist/materialize-media.js +35 -0
  35. package/dist/materialize-media.js.map +1 -0
  36. package/dist/media-notice.d.ts +6 -0
  37. package/dist/media-notice.d.ts.map +1 -0
  38. package/dist/media-notice.js +50 -0
  39. package/dist/media-notice.js.map +1 -0
  40. package/dist/plugin-entrypoint.d.ts +7 -0
  41. package/dist/plugin-entrypoint.d.ts.map +1 -0
  42. package/dist/plugin-entrypoint.js +445 -0
  43. package/dist/plugin-entrypoint.js.map +1 -0
  44. package/dist/reply-sender-map.d.ts +10 -0
  45. package/dist/reply-sender-map.d.ts.map +1 -0
  46. package/dist/reply-sender-map.js +123 -0
  47. package/dist/reply-sender-map.js.map +1 -0
  48. package/dist/runtime.d.ts +61 -0
  49. package/dist/runtime.d.ts.map +1 -0
  50. package/dist/runtime.js +82 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/telegram-html-formatter.d.ts +3 -0
  53. package/dist/telegram-html-formatter.d.ts.map +1 -0
  54. package/dist/telegram-html-formatter.js +42 -0
  55. package/dist/telegram-html-formatter.js.map +1 -0
  56. package/dist/thread-mappings.d.ts +46 -0
  57. package/dist/thread-mappings.d.ts.map +1 -0
  58. package/dist/thread-mappings.js +62 -0
  59. package/dist/thread-mappings.js.map +1 -0
  60. package/dist/token.d.ts +2 -0
  61. package/dist/token.d.ts.map +1 -0
  62. package/dist/token.js +8 -0
  63. package/dist/token.js.map +1 -0
  64. package/dist/types.d.ts +58 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +2 -0
  67. package/dist/types.js.map +1 -0
  68. package/npm-shrinkwrap.json +229 -0
  69. package/package.json +55 -4
  70. package/plugin.yaml +59 -0
@@ -0,0 +1,923 @@
1
+ /**
2
+ * Telegram Bot Adapter
3
+ * Inbound: Parse Telegram update → extract private text message
4
+ * Outbound: Send reply via Bot API
5
+ *
6
+ * Uses grammy for long polling (no public webhook needed).
7
+ * MVP: DM-only, text-only, single-owner.
8
+ *
9
+ * F088 Multi-Platform Chat Gateway
10
+ */
11
+ import { Bot, GrammyError, InputFile } from 'grammy';
12
+ import { fetchBoundedInboundMedia, INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, } from './inbound-download.js';
13
+ import { formatTelegramHtml } from './telegram-html-formatter.js';
14
+ import { materializeMedia } from './materialize-media.js';
15
+ // Telegram Bot API multipart limits: photos 10 MB; other uploaded files 50 MB.
16
+ // Source: https://core.telegram.org/bots/api#sending-files
17
+ export const TELEGRAM_MEDIA_MAX_BYTES = {
18
+ image: 10_000_000,
19
+ file: 50_000_000,
20
+ audio: 50_000_000,
21
+ };
22
+ const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
23
+ const TELEGRAM_POLLING_BACKOFF_MS = [5_000, 15_000, 30_000, 60_000];
24
+ const TELEGRAM_MAX_CONFLICT_RETRIES = 10;
25
+ function splitText(text) {
26
+ if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH)
27
+ return [text];
28
+ const parts = [];
29
+ let start = 0;
30
+ while (start < text.length) {
31
+ let end = Math.min(start + TELEGRAM_MAX_MESSAGE_LENGTH, text.length);
32
+ // Back up one code unit if we'd split a surrogate pair (high surrogate at boundary).
33
+ if (end < text.length) {
34
+ const charCode = text.charCodeAt(end - 1);
35
+ if (charCode >= 0xd800 && charCode <= 0xdbff)
36
+ end--;
37
+ }
38
+ parts.push(text.slice(start, end));
39
+ start = end;
40
+ }
41
+ return parts;
42
+ }
43
+ function splitHtml(html) {
44
+ if (html.length <= TELEGRAM_MAX_MESSAGE_LENGTH)
45
+ return [html];
46
+ const parts = [];
47
+ let start = 0;
48
+ while (start < html.length) {
49
+ let end = Math.min(start + TELEGRAM_MAX_MESSAGE_LENGTH, html.length);
50
+ if (end < html.length) {
51
+ // Don't split a surrogate pair
52
+ if ((html.charCodeAt(end - 1) & 0xfc00) === 0xd800)
53
+ end--;
54
+ // Don't split inside an HTML entity (&amp; &lt; &gt;)
55
+ const entityStart = html.lastIndexOf('&', end - 1);
56
+ if (entityStart >= start) {
57
+ const entityEnd = html.indexOf(';', entityStart);
58
+ if (entityEnd === -1 || entityEnd >= end)
59
+ end = entityStart;
60
+ }
61
+ // Don't split inside a tag
62
+ if (end > start) {
63
+ const tagStart = html.lastIndexOf('<', end - 1);
64
+ if (tagStart >= start) {
65
+ const tagEnd = html.indexOf('>', tagStart);
66
+ if (tagEnd === -1 || tagEnd >= end)
67
+ end = tagStart;
68
+ }
69
+ }
70
+ if (end <= start)
71
+ end = start + 1;
72
+ }
73
+ parts.push(html.slice(start, end));
74
+ start = end;
75
+ }
76
+ return parts;
77
+ }
78
+ function isTelegramHtmlParseError(err) {
79
+ if (!err || typeof err !== 'object')
80
+ return false;
81
+ const e = err;
82
+ if (e.error_code !== 400)
83
+ return false;
84
+ const desc = (e.description ?? e.message ?? '').toLowerCase();
85
+ return desc.includes('parse entities') || desc.includes('button_data_invalid');
86
+ }
87
+ // A crash redelivery can replay an edit whose content the first attempt
88
+ // already applied; Telegram rejects identical text with 400
89
+ // "message is not modified". That state IS the applied edit, not a failure.
90
+ function isTelegramNotModifiedError(err) {
91
+ if (!err || typeof err !== 'object')
92
+ return false;
93
+ const e = err;
94
+ if (e.error_code !== 400)
95
+ return false;
96
+ return (e.description ?? e.message ?? '').toLowerCase().includes('not modified');
97
+ }
98
+ /** (executionId, chatId) placeholders already consumed by a K2 inline final must not be edited again. */
99
+ const CONSUMED_INLINE_FINAL_TTL_MS = 24 * 60 * 60 * 1000;
100
+ /**
101
+ * One thread can back several Telegram bindings, so correlation is keyed by
102
+ * (executionId, chatId): a second binding's registration must never evict the
103
+ * first binding's pending placeholder for the same lifecycle.
104
+ */
105
+ function inlineFinalKey(executionId, externalChatId) {
106
+ return `${executionId}:${externalChatId}`;
107
+ }
108
+ function isTelegramConflictError(err) {
109
+ if (err instanceof GrammyError)
110
+ return err.error_code === 409;
111
+ if (!err || typeof err !== 'object')
112
+ return false;
113
+ const errorCode = err.error_code;
114
+ return errorCode === 409;
115
+ }
116
+ export class TelegramAdapter {
117
+ connectorId = 'telegram';
118
+ bot;
119
+ botToken;
120
+ log;
121
+ sendMessageFn = null;
122
+ placeholderChats = new Map();
123
+ /** Legacy deliveries without executionId remain FIFO; lifecycle deliveries use the exact map below. */
124
+ pendingInlineFinal = new Map();
125
+ /** Lifecycle+chat-keyed placeholders already consumed by a K2 inline final must not be edited again. */
126
+ pendingInlineFinalByLifecycle = new Map();
127
+ /** (executionId, chatId) -> consumedAt. A fresh `started` clears the marker for its key. */
128
+ consumedInlineFinal = new Map();
129
+ inlineFinalPersistence;
130
+ botApiSendMessageFn = null;
131
+ botApiEditMessageFn = null;
132
+ botApiDeleteMessageFn = null;
133
+ sendMediaFns = null;
134
+ pollingStopped = false;
135
+ pollingRunId = 0;
136
+ pollingControls = null;
137
+ inboundFetchFn = globalThis.fetch;
138
+ inboundMediaTimeoutMs = INBOUND_MEDIA_TIMEOUT_MS;
139
+ getFileFn = null;
140
+ constructor(botToken, log, inlineFinalPersistence) {
141
+ this.botToken = botToken;
142
+ this.bot = new Bot(botToken);
143
+ this.log = log;
144
+ this.inlineFinalPersistence = inlineFinalPersistence;
145
+ }
146
+ async downloadInboundMedia(locator) {
147
+ const controller = new AbortController();
148
+ const timer = setTimeout(() => controller.abort(new Error('Telegram inbound media download timed out')), this.inboundMediaTimeoutMs);
149
+ timer.unref?.();
150
+ try {
151
+ const file = await (this.getFileFn === null
152
+ ? this.bot.api.getFile(locator.platformKey, controller.signal)
153
+ : this.getFileFn(locator.platformKey, controller.signal));
154
+ if (!file.file_path)
155
+ throw new Error('Telegram getFile returned no file_path');
156
+ const { response, bytes } = await fetchBoundedInboundMedia(this.inboundFetchFn, `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`, { signal: controller.signal }, INBOUND_MEDIA_MAX_BYTES, this.inboundMediaTimeoutMs);
157
+ if (!response.ok)
158
+ throw new Error(`Telegram media download HTTP ${response.status}`);
159
+ return bytes;
160
+ }
161
+ finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+ getPollingControls() {
166
+ return (this.pollingControls ?? {
167
+ start: (options) => this.bot.start(options),
168
+ stop: () => this.bot.stop(),
169
+ close: () => this.bot.api.close(),
170
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
171
+ backoffMs: TELEGRAM_POLLING_BACKOFF_MS,
172
+ maxConflictRetries: TELEGRAM_MAX_CONFLICT_RETRIES,
173
+ });
174
+ }
175
+ /**
176
+ * Parse a Telegram update into an inbound message.
177
+ * Supports text, photo, document, and voice messages.
178
+ * Returns null for group or bot messages.
179
+ */
180
+ parseUpdate(update) {
181
+ if (!update || typeof update !== 'object')
182
+ return null;
183
+ const u = update;
184
+ const message = u.message;
185
+ if (!message)
186
+ return null;
187
+ // MVP: DM only (private chats)
188
+ const chat = message.chat;
189
+ if (!chat || chat.type !== 'private')
190
+ return null;
191
+ // Skip bot messages
192
+ const from = message.from;
193
+ if (!from || from.is_bot === true)
194
+ return null;
195
+ const base = {
196
+ chatId: String(chat.id),
197
+ messageId: String(message.message_id),
198
+ senderId: String(from.id),
199
+ };
200
+ const caption = typeof message.caption === 'string' ? message.caption : undefined;
201
+ // Text message
202
+ const text = message.text;
203
+ if (typeof text === 'string') {
204
+ return { ...base, text };
205
+ }
206
+ // Photo message — pick largest photo (last in array)
207
+ const photo = message.photo;
208
+ if (Array.isArray(photo) && photo.length > 0) {
209
+ const largest = photo[photo.length - 1];
210
+ return {
211
+ ...base,
212
+ text: caption ?? '[图片]',
213
+ attachments: [{ type: 'image', telegramFileId: largest.file_id }],
214
+ };
215
+ }
216
+ // Document message
217
+ const document = message.document;
218
+ if (document) {
219
+ const fileName = document.file_name;
220
+ return {
221
+ ...base,
222
+ text: caption ?? (fileName ? `[文件] ${fileName}` : '[文件]'),
223
+ attachments: [{ type: 'file', telegramFileId: document.file_id, ...(fileName ? { fileName } : {}) }],
224
+ };
225
+ }
226
+ // Voice message
227
+ const voice = message.voice;
228
+ if (voice) {
229
+ const duration = voice.duration;
230
+ return {
231
+ ...base,
232
+ text: '[语音]',
233
+ attachments: [
234
+ { type: 'audio', telegramFileId: voice.file_id, ...(duration != null ? { duration } : {}) },
235
+ ],
236
+ };
237
+ }
238
+ return null;
239
+ }
240
+ /**
241
+ * Send a reply to a Telegram chat.
242
+ * K2: If a executionId is supplied, edits only that lifecycle's placeholder. Legacy
243
+ * deliveries without executionId retain the per-chat FIFO behavior.
244
+ * If editMessage fails (message deleted etc.), falls back to sending a new message.
245
+ * K3: Splits content exceeding 4096 chars into multiple messages.
246
+ */
247
+ async sendReply(externalChatId, content, _metadata, executionId) {
248
+ const inlineMsgId = this.takeInlinePlaceholder(externalChatId, executionId);
249
+ if (inlineMsgId !== undefined) {
250
+ const [firstPart, ...restParts] = splitText(content);
251
+ let editSucceeded = false;
252
+ try {
253
+ await this.editMessage(externalChatId, inlineMsgId, firstPart);
254
+ editSucceeded = true;
255
+ this.placeholderChats.delete(inlineMsgId);
256
+ }
257
+ catch (err) {
258
+ this.log.warn({ err }, '[TelegramAdapter] sendReply: editMessage failed, falling back to send');
259
+ // ID already consumed from queue; delete the stale streaming card before sending.
260
+ await this.deleteMessage(inlineMsgId, externalChatId).catch(() => { });
261
+ }
262
+ if (editSucceeded) {
263
+ // ID already consumed above — send any remaining split parts.
264
+ for (const part of restParts) {
265
+ if (this.sendMessageFn) {
266
+ await this.sendMessageFn(externalChatId, part);
267
+ }
268
+ else {
269
+ await this.bot.api.sendMessage(externalChatId, part);
270
+ }
271
+ }
272
+ return;
273
+ }
274
+ for (const segment of splitText(content)) {
275
+ if (this.sendMessageFn) {
276
+ await this.sendMessageFn(externalChatId, segment);
277
+ }
278
+ else {
279
+ await this.bot.api.sendMessage(externalChatId, segment);
280
+ }
281
+ }
282
+ return;
283
+ }
284
+ for (const segment of splitText(content)) {
285
+ if (this.sendMessageFn) {
286
+ await this.sendMessageFn(externalChatId, segment);
287
+ }
288
+ else {
289
+ await this.bot.api.sendMessage(externalChatId, segment);
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Start long polling for inbound messages.
295
+ * Handles text, photo, document, and voice DMs.
296
+ */
297
+ startPolling(handler) {
298
+ this.pollingStopped = false;
299
+ const runId = ++this.pollingRunId;
300
+ const handleUpdate = async (ctx) => {
301
+ if (!ctx.message)
302
+ return;
303
+ const parsed = this.parseUpdate({ message: ctx.message });
304
+ if (!parsed)
305
+ return;
306
+ try {
307
+ await handler(parsed);
308
+ }
309
+ catch (err) {
310
+ this.log.error({ err, chatId: parsed.chatId }, '[TelegramAdapter] Handler error');
311
+ }
312
+ };
313
+ this.bot.on('message:text', handleUpdate);
314
+ this.bot.on('message:photo', handleUpdate);
315
+ this.bot.on('message:document', handleUpdate);
316
+ this.bot.on('message:voice', handleUpdate);
317
+ // Fire-and-forget: the loop swallows its own errors, and this trailing
318
+ // catch keeps a late throw (e.g. a logger that died with the feature
319
+ // context) from surfacing as an unhandled rejection in the Host process.
320
+ void this.runPollingLoop(runId).catch(() => { });
321
+ }
322
+ async runPollingLoop(runId) {
323
+ const controls = this.getPollingControls();
324
+ let attempt = 0;
325
+ while (!this.pollingStopped && runId === this.pollingRunId) {
326
+ try {
327
+ await controls.start({
328
+ onStart: () => {
329
+ attempt = 0;
330
+ this.log.info('[TelegramAdapter] Long polling started');
331
+ },
332
+ });
333
+ return;
334
+ }
335
+ catch (err) {
336
+ if (this.pollingStopped || runId !== this.pollingRunId)
337
+ return;
338
+ if (!isTelegramConflictError(err)) {
339
+ this.log.error({ err }, '[TelegramAdapter] Long polling failed');
340
+ return;
341
+ }
342
+ const shouldRetry = await this.recoverPollingConflict(err, controls, attempt);
343
+ if (!shouldRetry)
344
+ return;
345
+ attempt += 1;
346
+ }
347
+ }
348
+ }
349
+ async recoverPollingConflict(err, controls, attempt) {
350
+ if (attempt >= controls.maxConflictRetries) {
351
+ this.log.error({ err, attempts: attempt }, '[TelegramAdapter] 409 conflict retry limit reached');
352
+ return false;
353
+ }
354
+ const waitMs = controls.backoffMs[Math.min(attempt, controls.backoffMs.length - 1)] ?? controls.backoffMs.at(-1) ?? 60_000;
355
+ this.log.warn({ err, attempt: attempt + 1, waitMs }, '[TelegramAdapter] 409 conflict; releasing session and retrying');
356
+ try {
357
+ await controls.close();
358
+ }
359
+ catch (closeErr) {
360
+ this.log.warn({ err: closeErr }, '[TelegramAdapter] bot.api.close() failed during 409 recovery');
361
+ }
362
+ await controls.sleep(waitMs);
363
+ return true;
364
+ }
365
+ /**
366
+ * Stop long polling gracefully.
367
+ */
368
+ async stopPolling() {
369
+ this.pollingStopped = true;
370
+ this.pollingRunId += 1;
371
+ const controls = this.getPollingControls();
372
+ try {
373
+ await controls.stop();
374
+ }
375
+ catch (err) {
376
+ this.log.warn({ err }, '[TelegramAdapter] bot.stop() failed');
377
+ }
378
+ try {
379
+ await controls.close();
380
+ }
381
+ catch (err) {
382
+ this.log.warn({ err }, '[TelegramAdapter] bot.api.close() failed');
383
+ }
384
+ }
385
+ /**
386
+ * Send a rich message as Telegram HTML-formatted text.
387
+ * K2: If a executionId is supplied, edits only that lifecycle's placeholder. Legacy
388
+ * deliveries without executionId retain the per-chat FIFO behavior.
389
+ * Falls back to plain text edit if HTML parse fails.
390
+ * Falls back to sending a new message if editMessage fails entirely.
391
+ * K3: HTML parse error falls back to plain text; long plain text is split.
392
+ */
393
+ async sendRichMessage(externalChatId, textContent, blocks, catDisplayName, executionId) {
394
+ const html = formatTelegramHtml(blocks, catDisplayName, textContent);
395
+ const inlineMsgId = this.takeInlinePlaceholder(externalChatId, executionId);
396
+ if (inlineMsgId !== undefined) {
397
+ const [firstHtmlPart, ...restHtmlParts] = splitHtml(html);
398
+ let richEditSucceeded = false;
399
+ try {
400
+ await this.editMessage(externalChatId, inlineMsgId, firstHtmlPart, { parse_mode: 'HTML' });
401
+ richEditSucceeded = true;
402
+ this.placeholderChats.delete(inlineMsgId);
403
+ }
404
+ catch (err) {
405
+ this.log.warn({ err }, '[TelegramAdapter] sendRichMessage: editMessage failed, falling back to send');
406
+ // ID already consumed; delete the stale streaming card before sending.
407
+ await this.deleteMessage(inlineMsgId, externalChatId).catch(() => { });
408
+ }
409
+ if (richEditSucceeded) {
410
+ // ID already consumed above — send any overflow segments as new messages.
411
+ // K3 cloud-R11 P1: apply the same HTML-parse-error fallback as the non-inline path.
412
+ let overflowUseHtml = true;
413
+ for (const part of restHtmlParts) {
414
+ if (!overflowUseHtml) {
415
+ const stripped = part
416
+ .replace(/<[^>]+>/g, '')
417
+ .replace(/&amp;/g, '&')
418
+ .replace(/&lt;/g, '<')
419
+ .replace(/&gt;/g, '>')
420
+ .trim();
421
+ if (this.sendMessageFn) {
422
+ await this.sendMessageFn(externalChatId, stripped || part);
423
+ }
424
+ else {
425
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
426
+ }
427
+ continue;
428
+ }
429
+ try {
430
+ if (this.sendMessageFn) {
431
+ await this.sendMessageFn(externalChatId, part, { parse_mode: 'HTML' });
432
+ }
433
+ else {
434
+ await this.bot.api.sendMessage(externalChatId, part, { parse_mode: 'HTML' });
435
+ }
436
+ }
437
+ catch (htmlErr) {
438
+ if (!isTelegramHtmlParseError(htmlErr))
439
+ throw htmlErr;
440
+ overflowUseHtml = false;
441
+ const stripped = part
442
+ .replace(/<[^>]+>/g, '')
443
+ .replace(/&amp;/g, '&')
444
+ .replace(/&lt;/g, '<')
445
+ .replace(/&gt;/g, '>')
446
+ .trim();
447
+ if (this.sendMessageFn) {
448
+ await this.sendMessageFn(externalChatId, stripped || part);
449
+ }
450
+ else {
451
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
452
+ }
453
+ }
454
+ }
455
+ return;
456
+ }
457
+ // Fallback: split long HTML across multiple messages.
458
+ // Mid-stream HTML parse error: switch to plain-text for the failing chunk and all remaining.
459
+ let inlineSentCount = 0;
460
+ let inlineUseHtml = true;
461
+ for (const part of splitHtml(html)) {
462
+ if (!inlineUseHtml) {
463
+ const stripped = part
464
+ .replace(/<[^>]+>/g, '')
465
+ .replace(/&amp;/g, '&')
466
+ .replace(/&lt;/g, '<')
467
+ .replace(/&gt;/g, '>')
468
+ .trim();
469
+ if (this.sendMessageFn) {
470
+ await this.sendMessageFn(externalChatId, stripped || part);
471
+ }
472
+ else {
473
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
474
+ }
475
+ continue;
476
+ }
477
+ try {
478
+ if (this.sendMessageFn) {
479
+ await this.sendMessageFn(externalChatId, part, { parse_mode: 'HTML' });
480
+ }
481
+ else {
482
+ await this.bot.api.sendMessage(externalChatId, part, { parse_mode: 'HTML' });
483
+ }
484
+ inlineSentCount++;
485
+ }
486
+ catch (htmlErr) {
487
+ if (!isTelegramHtmlParseError(htmlErr))
488
+ throw htmlErr;
489
+ if (inlineSentCount === 0) {
490
+ this.log.warn({ err: htmlErr }, '[TelegramAdapter] sendRichMessage: HTML parse error, falling back to plain text');
491
+ const strippedHtml = html
492
+ .replace(/<[^>]+>/g, '')
493
+ .replace(/&amp;/g, '&')
494
+ .replace(/&lt;/g, '<')
495
+ .replace(/&gt;/g, '>')
496
+ .trim();
497
+ const plainFallback = strippedHtml || textContent;
498
+ for (const segment of splitText(plainFallback)) {
499
+ if (this.sendMessageFn) {
500
+ await this.sendMessageFn(externalChatId, segment);
501
+ }
502
+ else {
503
+ await this.bot.api.sendMessage(externalChatId, segment);
504
+ }
505
+ }
506
+ return;
507
+ }
508
+ inlineUseHtml = false;
509
+ const stripped = part
510
+ .replace(/<[^>]+>/g, '')
511
+ .replace(/&amp;/g, '&')
512
+ .replace(/&lt;/g, '<')
513
+ .replace(/&gt;/g, '>')
514
+ .trim();
515
+ if (this.sendMessageFn) {
516
+ await this.sendMessageFn(externalChatId, stripped || part);
517
+ }
518
+ else {
519
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
520
+ }
521
+ }
522
+ }
523
+ return;
524
+ }
525
+ // Non-inline send path: split long HTML across multiple messages.
526
+ // Mid-stream HTML parse error: switch to plain-text for the failing chunk and all remaining.
527
+ let sentCount = 0;
528
+ let useHtml = true;
529
+ for (const part of splitHtml(html)) {
530
+ if (!useHtml) {
531
+ const stripped = part
532
+ .replace(/<[^>]+>/g, '')
533
+ .replace(/&amp;/g, '&')
534
+ .replace(/&lt;/g, '<')
535
+ .replace(/&gt;/g, '>')
536
+ .trim();
537
+ if (this.sendMessageFn) {
538
+ await this.sendMessageFn(externalChatId, stripped || part);
539
+ }
540
+ else {
541
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
542
+ }
543
+ continue;
544
+ }
545
+ try {
546
+ if (this.sendMessageFn) {
547
+ await this.sendMessageFn(externalChatId, part, { parse_mode: 'HTML' });
548
+ }
549
+ else {
550
+ await this.bot.api.sendMessage(externalChatId, part, { parse_mode: 'HTML' });
551
+ }
552
+ sentCount++;
553
+ }
554
+ catch (htmlErr) {
555
+ if (!isTelegramHtmlParseError(htmlErr))
556
+ throw htmlErr;
557
+ if (sentCount === 0) {
558
+ this.log.warn({ err: htmlErr }, '[TelegramAdapter] sendRichMessage: HTML parse error, falling back to plain text');
559
+ const strippedHtml = html
560
+ .replace(/<[^>]+>/g, '')
561
+ .replace(/&amp;/g, '&')
562
+ .replace(/&lt;/g, '<')
563
+ .replace(/&gt;/g, '>')
564
+ .trim();
565
+ const plainFallback = strippedHtml || textContent;
566
+ for (const segment of splitText(plainFallback)) {
567
+ if (this.sendMessageFn) {
568
+ await this.sendMessageFn(externalChatId, segment);
569
+ }
570
+ else {
571
+ await this.bot.api.sendMessage(externalChatId, segment);
572
+ }
573
+ }
574
+ return;
575
+ }
576
+ useHtml = false;
577
+ const stripped = part
578
+ .replace(/<[^>]+>/g, '')
579
+ .replace(/&amp;/g, '&')
580
+ .replace(/&lt;/g, '<')
581
+ .replace(/&gt;/g, '>')
582
+ .trim();
583
+ if (this.sendMessageFn) {
584
+ await this.sendMessageFn(externalChatId, stripped || part);
585
+ }
586
+ else {
587
+ await this.bot.api.sendMessage(externalChatId, stripped || part);
588
+ }
589
+ }
590
+ }
591
+ }
592
+ /**
593
+ * Send a placeholder message for streaming and return its message ID.
594
+ * Records the externalChatId mapping so deleteMessage can clean it up later.
595
+ */
596
+ async sendPlaceholder(externalChatId, text) {
597
+ const msg = this.botApiSendMessageFn
598
+ ? await this.botApiSendMessageFn(Number(externalChatId), text)
599
+ : await this.bot.api.sendMessage(Number(externalChatId), text);
600
+ const msgId = String(msg.message_id);
601
+ this.placeholderChats.set(msgId, externalChatId);
602
+ return msgId;
603
+ }
604
+ /**
605
+ * Delete a placeholder message after outbound delivery succeeds.
606
+ * No-op if platformMessageId is unknown (delivery failed before placeholder was registered).
607
+ * Cleans up the mapping after deletion to prevent double-delete.
608
+ */
609
+ async deleteMessage(platformMessageId, externalChatId) {
610
+ // Prefer caller-provided chatId; fall back to Map for adapters that don't pass it.
611
+ // Telegram message_ids are only unique per-chat, so the Map alone is unsafe for multi-chat.
612
+ const chatId = externalChatId ?? this.placeholderChats.get(platformMessageId);
613
+ if (!chatId)
614
+ return;
615
+ try {
616
+ if (this.botApiDeleteMessageFn) {
617
+ await this.botApiDeleteMessageFn(Number(chatId), Number(platformMessageId));
618
+ }
619
+ else {
620
+ await this.bot.api.deleteMessage(Number(chatId), Number(platformMessageId));
621
+ }
622
+ }
623
+ finally {
624
+ this.placeholderChats.delete(platformMessageId);
625
+ }
626
+ }
627
+ /**
628
+ * Edit an already-sent message in place (for streaming progressive updates and K2 inline final).
629
+ * Truncates to Telegram's 4096-char limit.
630
+ * opts.parse_mode: pass 'HTML' when editing with rich HTML content (K2 sendRichMessage inline).
631
+ */
632
+ async editMessage(externalChatId, platformMessageId, text, opts) {
633
+ const truncated = text.length > TELEGRAM_MAX_MESSAGE_LENGTH ? `${text.slice(0, TELEGRAM_MAX_MESSAGE_LENGTH - 1)}…` : text;
634
+ try {
635
+ if (this.botApiEditMessageFn) {
636
+ await this.botApiEditMessageFn(Number(externalChatId), Number(platformMessageId), truncated, opts);
637
+ }
638
+ else if (opts?.parse_mode) {
639
+ await this.bot.api.editMessageText(Number(externalChatId), Number(platformMessageId), truncated, opts);
640
+ }
641
+ else {
642
+ await this.bot.api.editMessageText(Number(externalChatId), Number(platformMessageId), truncated);
643
+ }
644
+ }
645
+ catch (error) {
646
+ if (isTelegramNotModifiedError(error))
647
+ return true;
648
+ throw error;
649
+ }
650
+ return true;
651
+ }
652
+ /** Stop lifecycle-final correlation while leaving a blocked recovery message visible. */
653
+ preserveInlinePlaceholder(externalChatId, platformMessageId, executionId) {
654
+ const key = inlineFinalKey(executionId, externalChatId);
655
+ const pending = this.pendingInlineFinalByLifecycle.get(key);
656
+ if (pending?.externalChatId === externalChatId && pending.platformMessageId === platformMessageId) {
657
+ this.pendingInlineFinalByLifecycle.delete(key);
658
+ this.persistRemove(executionId, externalChatId);
659
+ }
660
+ this.placeholderChats.delete(platformMessageId);
661
+ }
662
+ /**
663
+ * K2: Register a pending inline-final placeholder.
664
+ * Lifecycle-aware deliveries use (executionId, chatId) as the exact correlation
665
+ * key, so two bindings of one thread never evict each other; callers without a
666
+ * executionId retain the legacy per-chat FIFO behavior. Consumed on first use.
667
+ * A new registration for a key also clears any consumed marker left by the
668
+ * previous round so its fresh placeholder stays editable.
669
+ */
670
+ registerInlinePlaceholder(externalChatId, platformMessageId, executionId) {
671
+ if (executionId !== undefined) {
672
+ const key = inlineFinalKey(executionId, externalChatId);
673
+ // Only touch durable storage when a marker actually existed; a fresh
674
+ // registration otherwise writes and deletes nothing.
675
+ if (this.consumedInlineFinal.delete(key))
676
+ this.persistRemoveConsumed(executionId, externalChatId);
677
+ this.pruneConsumedInlineFinals();
678
+ const registeredAt = Date.now();
679
+ this.pendingInlineFinalByLifecycle.set(key, { externalChatId, platformMessageId, registeredAt });
680
+ this.persistSave({ executionId, externalChatId, platformMessageId, registeredAt });
681
+ return;
682
+ }
683
+ const queue = this.pendingInlineFinal.get(externalChatId) ?? [];
684
+ queue.push(platformMessageId);
685
+ this.pendingInlineFinal.set(externalChatId, queue);
686
+ }
687
+ /** True once a K2 inline final consumed this (lifecycle, chat) placeholder; later lifecycle edits must not overwrite the delivered body. */
688
+ isInlineFinalConsumed(executionId, externalChatId) {
689
+ return this.consumedInlineFinal.has(inlineFinalKey(executionId, externalChatId));
690
+ }
691
+ /**
692
+ * Settle ends the lifecycle, closing the delivered-but-not-yet-settled window
693
+ * the consumed marker protects; the SDK allows only one started per
694
+ * lifecycle, so without this the marker would linger until the 24h TTL.
695
+ * Best-effort persisted removal, mirroring registerInlinePlaceholder.
696
+ */
697
+ clearInlineFinalConsumed(executionId, externalChatId) {
698
+ const key = inlineFinalKey(executionId, externalChatId);
699
+ if (this.consumedInlineFinal.delete(key))
700
+ this.persistRemoveConsumed(executionId, externalChatId);
701
+ }
702
+ /** Seed a pending inline-final recovered from durable storage after a plugin restart. */
703
+ restoreInlinePlaceholder(executionId, externalChatId, platformMessageId, registeredAt) {
704
+ this.pendingInlineFinalByLifecycle.set(inlineFinalKey(executionId, externalChatId), { externalChatId, platformMessageId, registeredAt });
705
+ }
706
+ /** Seed a consumed marker recovered from durable storage after a plugin restart. */
707
+ restoreInlineFinalConsumed(executionId, externalChatId, consumedAt) {
708
+ this.consumedInlineFinal.set(inlineFinalKey(executionId, externalChatId), consumedAt);
709
+ }
710
+ /**
711
+ * Warn without letting a dead logger turn a swallowed failure into an
712
+ * unhandled rejection: context.logger runs assertActive() and throws after
713
+ * feature revocation, and this plugin shares the Host process.
714
+ */
715
+ safeWarn(fields, message) {
716
+ try {
717
+ this.log.warn(fields, message);
718
+ }
719
+ catch {
720
+ // Logging is best-effort past revocation; nothing actionable remains.
721
+ }
722
+ }
723
+ persistSave(entry) {
724
+ if (this.inlineFinalPersistence === undefined)
725
+ return;
726
+ this.inlineFinalPersistence.save(entry).catch((error) => {
727
+ this.safeWarn({ err: error }, '[TelegramAdapter] inline-final persistence save failed');
728
+ });
729
+ }
730
+ persistRemove(executionId, externalChatId) {
731
+ if (this.inlineFinalPersistence === undefined)
732
+ return;
733
+ this.inlineFinalPersistence.remove(executionId, externalChatId).catch((error) => {
734
+ this.safeWarn({ err: error }, '[TelegramAdapter] inline-final persistence remove failed');
735
+ });
736
+ }
737
+ persistConsumed(executionId, externalChatId, consumedAt) {
738
+ if (this.inlineFinalPersistence === undefined)
739
+ return;
740
+ this.inlineFinalPersistence.saveConsumed({ executionId, externalChatId, consumedAt }).catch((error) => {
741
+ this.safeWarn({ err: error }, '[TelegramAdapter] inline-final consumed persistence save failed');
742
+ });
743
+ }
744
+ persistRemoveConsumed(executionId, externalChatId) {
745
+ if (this.inlineFinalPersistence === undefined)
746
+ return;
747
+ this.inlineFinalPersistence.removeConsumed(executionId, externalChatId).catch((error) => {
748
+ this.safeWarn({ err: error }, '[TelegramAdapter] inline-final consumed persistence remove failed');
749
+ });
750
+ }
751
+ pruneConsumedInlineFinals() {
752
+ const cutoff = Date.now() - CONSUMED_INLINE_FINAL_TTL_MS;
753
+ for (const [key, consumedAt] of this.consumedInlineFinal) {
754
+ if (consumedAt >= cutoff)
755
+ continue;
756
+ this.consumedInlineFinal.delete(key);
757
+ // Keep durable storage in step with the in-memory TTL eviction; the
758
+ // hydration sweep would only catch it at the next restart.
759
+ const sep = key.lastIndexOf(':');
760
+ if (sep > 0)
761
+ this.persistRemoveConsumed(key.slice(0, sep), key.slice(sep + 1));
762
+ }
763
+ }
764
+ takeInlinePlaceholder(externalChatId, executionId) {
765
+ if (executionId !== undefined) {
766
+ const key = inlineFinalKey(executionId, externalChatId);
767
+ const pending = this.pendingInlineFinalByLifecycle.get(key);
768
+ if (pending?.externalChatId !== externalChatId)
769
+ return undefined;
770
+ this.pendingInlineFinalByLifecycle.delete(key);
771
+ this.persistRemove(executionId, externalChatId);
772
+ // The final body now lives in the placeholder message; any later
773
+ // catching_up/blocked edit for this (lifecycle, chat) must not overwrite
774
+ // it. The marker is persisted too, so a restart inside the
775
+ // delivered-but-not-yet-settled window cannot let a redelivery rewrite
776
+ // the delivered body.
777
+ const consumedAt = Date.now();
778
+ this.consumedInlineFinal.set(key, consumedAt);
779
+ this.persistConsumed(executionId, externalChatId, consumedAt);
780
+ return pending.platformMessageId;
781
+ }
782
+ const queue = this.pendingInlineFinal.get(externalChatId);
783
+ // Consume before the async edit so concurrent legacy deliveries cannot select the same ID.
784
+ const platformMessageId = queue?.shift();
785
+ if (queue !== undefined && queue.length === 0)
786
+ this.pendingInlineFinal.delete(externalChatId);
787
+ return platformMessageId;
788
+ }
789
+ /**
790
+ * K2: Clear a registered inline-final placeholder without delivering content.
791
+ * Called when delivery is skipped so stale state doesn't corrupt the next delivery.
792
+ * Removes the specific platformMessageId from the FIFO queue (K3: queue per chatId).
793
+ * If the placeholder was already consumed by sendReply/sendRichMessage, this is a no-op.
794
+ * Deletes the streaming card from Telegram when entry was still pending (delivery skipped).
795
+ */
796
+ async clearInlinePlaceholder(chatId, platformMessageId, executionId) {
797
+ if (platformMessageId) {
798
+ if (executionId !== undefined) {
799
+ const key = inlineFinalKey(executionId, chatId);
800
+ const pending = this.pendingInlineFinalByLifecycle.get(key);
801
+ if (pending?.externalChatId === chatId && pending.platformMessageId === platformMessageId) {
802
+ this.pendingInlineFinalByLifecycle.delete(key);
803
+ this.persistRemove(executionId, chatId);
804
+ await this.deleteMessage(platformMessageId, chatId).catch(() => { });
805
+ }
806
+ else {
807
+ // The matching final delivery already consumed this lifecycle placeholder.
808
+ this.placeholderChats.delete(platformMessageId);
809
+ }
810
+ return;
811
+ }
812
+ const queue = this.pendingInlineFinal.get(chatId);
813
+ if (queue) {
814
+ const idx = queue.indexOf(platformMessageId);
815
+ if (idx !== -1) {
816
+ // ID still pending: delivery was skipped — remove from queue and delete the streaming card.
817
+ queue.splice(idx, 1);
818
+ if (queue.length === 0)
819
+ this.pendingInlineFinal.delete(chatId);
820
+ await this.deleteMessage(platformMessageId, chatId).catch(() => { });
821
+ }
822
+ else {
823
+ // ID already consumed by delivery — just clean the tracking map to prevent unbounded growth.
824
+ this.placeholderChats.delete(platformMessageId);
825
+ }
826
+ }
827
+ else {
828
+ // No queue at all — ID was consumed and queue was deleted — still clean tracking map.
829
+ this.placeholderChats.delete(platformMessageId);
830
+ }
831
+ }
832
+ else {
833
+ if (executionId !== undefined) {
834
+ // Without a platformMessageId we cannot tell which binding's entry to
835
+ // clear, so this only finds anything when a single binding registered.
836
+ const scoped = `${executionId}:`;
837
+ for (const [key, pending] of this.pendingInlineFinalByLifecycle) {
838
+ if (!key.startsWith(scoped) || pending.externalChatId !== chatId)
839
+ continue;
840
+ this.pendingInlineFinalByLifecycle.delete(key);
841
+ this.persistRemove(executionId, chatId);
842
+ }
843
+ }
844
+ else {
845
+ this.pendingInlineFinal.delete(chatId);
846
+ }
847
+ }
848
+ }
849
+ /** Send Host-authorized bytes to Telegram before the action entitlement closes. */
850
+ async sendMedia(externalChatId, payload) {
851
+ const chatId = Number(externalChatId);
852
+ const materialized = await materializeMedia(payload.content, payload.fileName, TELEGRAM_MEDIA_MAX_BYTES[payload.type]);
853
+ const source = new InputFile(materialized.path);
854
+ const fns = this.sendMediaFns ?? {
855
+ sendPhoto: (cid, input) => this.bot.api.sendPhoto(cid, input),
856
+ sendDocument: (cid, input) => this.bot.api.sendDocument(cid, input),
857
+ sendVoice: (cid, input) => this.bot.api.sendVoice(cid, input),
858
+ };
859
+ try {
860
+ switch (payload.type) {
861
+ case 'image':
862
+ await fns.sendPhoto(chatId, source);
863
+ break;
864
+ case 'file':
865
+ await fns.sendDocument(chatId, source);
866
+ break;
867
+ case 'audio':
868
+ await fns.sendVoice(chatId, source);
869
+ break;
870
+ }
871
+ }
872
+ finally {
873
+ await materialized.cleanup().catch(() => undefined);
874
+ }
875
+ }
876
+ /**
877
+ * Test helper: inject a mock sendMessage function.
878
+ * @internal
879
+ */
880
+ _injectSendMessage(fn) {
881
+ this.sendMessageFn = fn;
882
+ }
883
+ /**
884
+ * Test helper: inject mock media send functions.
885
+ * @internal
886
+ */
887
+ _injectSendMedia(fns) {
888
+ this.sendMediaFns = fns;
889
+ }
890
+ /** @internal */
891
+ _injectGetFile(fn) {
892
+ this.getFileFn = fn;
893
+ }
894
+ /** @internal */
895
+ _injectInboundFetch(fn) {
896
+ this.inboundFetchFn = fn;
897
+ }
898
+ /** @internal */
899
+ _injectInboundMediaTimeout(timeoutMs) {
900
+ this.inboundMediaTimeoutMs = timeoutMs;
901
+ }
902
+ /** @internal */
903
+ _injectBotApiSendMessage(fn) {
904
+ this.botApiSendMessageFn = fn;
905
+ }
906
+ /** @internal */
907
+ _injectBotApiEditMessage(fn) {
908
+ this.botApiEditMessageFn = fn;
909
+ }
910
+ /** @internal */
911
+ _injectBotApiDeleteMessage(fn) {
912
+ this.botApiDeleteMessageFn = fn;
913
+ }
914
+ /**
915
+ * Test helper: inject long polling lifecycle controls.
916
+ * @internal
917
+ */
918
+ _injectPollingControls(fns) {
919
+ const defaults = this.getPollingControls();
920
+ this.pollingControls = { ...defaults, ...fns };
921
+ }
922
+ }
923
+ //# sourceMappingURL=TelegramAdapter.js.map