@clowder-ai/connector-telegram 0.0.0-stage → 0.1.0-alpha.5
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/README.en.md +48 -0
- package/README.md +24 -2
- package/assets/icon.svg +4 -0
- package/dist/TelegramAdapter.d.ts +229 -0
- package/dist/TelegramAdapter.d.ts.map +1 -0
- package/dist/TelegramAdapter.js +923 -0
- package/dist/TelegramAdapter.js.map +1 -0
- package/dist/command-ingress.d.ts +10 -0
- package/dist/command-ingress.d.ts.map +1 -0
- package/dist/command-ingress.js +13 -0
- package/dist/command-ingress.js.map +1 -0
- package/dist/execution-status.d.ts +27 -0
- package/dist/execution-status.d.ts.map +1 -0
- package/dist/execution-status.js +48 -0
- package/dist/execution-status.js.map +1 -0
- package/dist/inbound-download.d.ts +13 -0
- package/dist/inbound-download.d.ts.map +1 -0
- package/dist/inbound-download.js +70 -0
- package/dist/inbound-download.js.map +1 -0
- package/dist/inbound-media-source.d.ts +25 -0
- package/dist/inbound-media-source.d.ts.map +1 -0
- package/dist/inbound-media-source.js +111 -0
- package/dist/inbound-media-source.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-action.d.ts +30 -0
- package/dist/lifecycle-action.d.ts.map +1 -0
- package/dist/lifecycle-action.js +457 -0
- package/dist/lifecycle-action.js.map +1 -0
- package/dist/materialize-media.d.ts +7 -0
- package/dist/materialize-media.d.ts.map +1 -0
- package/dist/materialize-media.js +35 -0
- package/dist/materialize-media.js.map +1 -0
- package/dist/media-notice.d.ts +6 -0
- package/dist/media-notice.d.ts.map +1 -0
- package/dist/media-notice.js +50 -0
- package/dist/media-notice.js.map +1 -0
- package/dist/plugin-entrypoint.d.ts +7 -0
- package/dist/plugin-entrypoint.d.ts.map +1 -0
- package/dist/plugin-entrypoint.js +445 -0
- package/dist/plugin-entrypoint.js.map +1 -0
- package/dist/reply-sender-map.d.ts +10 -0
- package/dist/reply-sender-map.d.ts.map +1 -0
- package/dist/reply-sender-map.js +123 -0
- package/dist/reply-sender-map.js.map +1 -0
- package/dist/runtime.d.ts +61 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +82 -0
- package/dist/runtime.js.map +1 -0
- package/dist/telegram-html-formatter.d.ts +3 -0
- package/dist/telegram-html-formatter.d.ts.map +1 -0
- package/dist/telegram-html-formatter.js +42 -0
- package/dist/telegram-html-formatter.js.map +1 -0
- package/dist/thread-mappings.d.ts +46 -0
- package/dist/thread-mappings.d.ts.map +1 -0
- package/dist/thread-mappings.js +62 -0
- package/dist/thread-mappings.js.map +1 -0
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +8 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/npm-shrinkwrap.json +229 -0
- package/package.json +55 -4
- 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 (& < >)
|
|
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(/&/g, '&')
|
|
418
|
+
.replace(/</g, '<')
|
|
419
|
+
.replace(/>/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(/&/g, '&')
|
|
444
|
+
.replace(/</g, '<')
|
|
445
|
+
.replace(/>/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(/&/g, '&')
|
|
466
|
+
.replace(/</g, '<')
|
|
467
|
+
.replace(/>/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(/&/g, '&')
|
|
494
|
+
.replace(/</g, '<')
|
|
495
|
+
.replace(/>/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(/&/g, '&')
|
|
512
|
+
.replace(/</g, '<')
|
|
513
|
+
.replace(/>/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(/&/g, '&')
|
|
534
|
+
.replace(/</g, '<')
|
|
535
|
+
.replace(/>/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(/&/g, '&')
|
|
562
|
+
.replace(/</g, '<')
|
|
563
|
+
.replace(/>/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(/&/g, '&')
|
|
580
|
+
.replace(/</g, '<')
|
|
581
|
+
.replace(/>/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
|