telegram-agent-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,803 @@
1
+ // src/errors.ts
2
+ var TelegramApiError = class extends Error {
3
+ constructor(error_code, description) {
4
+ super(`telegram api ${error_code}${description ? `: ${description}` : ""}`);
5
+ this.error_code = error_code;
6
+ this.description = description;
7
+ this.name = "TelegramApiError";
8
+ }
9
+ error_code;
10
+ description;
11
+ };
12
+ function isBadRequest(err) {
13
+ return err instanceof TelegramApiError && err.error_code === 400;
14
+ }
15
+
16
+ // src/format/chunk.ts
17
+ var TELEGRAM_LIMIT = 4096;
18
+ var CHUNK_TARGET = 4e3;
19
+ var RICH_LIMIT = 32768;
20
+ function chunkText(text) {
21
+ if (text.length <= TELEGRAM_LIMIT) return [text];
22
+ const chunks = [];
23
+ let rest = text;
24
+ while (rest.length > TELEGRAM_LIMIT) {
25
+ let cut = rest.lastIndexOf("\n\n", CHUNK_TARGET);
26
+ if (cut < 1) cut = rest.lastIndexOf("\n", CHUNK_TARGET);
27
+ if (cut < 1) cut = CHUNK_TARGET;
28
+ const code = rest.charCodeAt(cut - 1);
29
+ if (code >= 55296 && code <= 56319) cut -= 1;
30
+ chunks.push(rest.slice(0, cut));
31
+ rest = rest.slice(cut);
32
+ }
33
+ if (rest.length > 0) chunks.push(rest);
34
+ return chunks;
35
+ }
36
+ function safeSlice(text, max) {
37
+ if (text.length <= max) return text;
38
+ let cut = max;
39
+ const code = text.charCodeAt(cut - 1);
40
+ if (code >= 55296 && code <= 56319) cut -= 1;
41
+ return text.slice(0, cut);
42
+ }
43
+ function chunkRich(md) {
44
+ if (md.length <= RICH_LIMIT) return [md];
45
+ const out = [];
46
+ let cur = "";
47
+ for (const block of md.split("\n\n")) {
48
+ const candidate = cur === "" ? block : `${cur}
49
+
50
+ ${block}`;
51
+ if (candidate.length <= RICH_LIMIT) {
52
+ cur = candidate;
53
+ continue;
54
+ }
55
+ if (cur !== "") {
56
+ out.push(cur);
57
+ cur = "";
58
+ }
59
+ if (block.length <= RICH_LIMIT) {
60
+ cur = block;
61
+ continue;
62
+ }
63
+ let rest = block;
64
+ while (rest.length > RICH_LIMIT) {
65
+ const cut = safeSlice(rest, RICH_LIMIT);
66
+ out.push(cut);
67
+ rest = rest.slice(cut.length);
68
+ }
69
+ cur = rest;
70
+ }
71
+ if (cur !== "") out.push(cur);
72
+ return out;
73
+ }
74
+
75
+ // src/format/md-to-html.ts
76
+ var escapeHtml = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
77
+ var escapeAttr = (s) => escapeHtml(s).replaceAll('"', "&quot;");
78
+ var MARKS = [
79
+ { marker: "**", tag: "b", underscore: false, single: false },
80
+ { marker: "__", tag: "b", underscore: true, single: false },
81
+ { marker: "*", tag: "i", underscore: false, single: true },
82
+ { marker: "_", tag: "i", underscore: true, single: true },
83
+ { marker: "~~", tag: "s", underscore: false, single: false }
84
+ ];
85
+ var WORD_RE = /[\p{L}\p{N}_]/u;
86
+ var isWord = (ch) => ch !== void 0 && WORD_RE.test(ch);
87
+ var isSpace = (ch) => ch !== void 0 && /\s/.test(ch);
88
+ function findOpener(text, m, from) {
89
+ for (let p = text.indexOf(m.marker, from); p !== -1; p = text.indexOf(m.marker, p + 1)) {
90
+ const next = text[p + m.marker.length];
91
+ if (next === void 0 || isSpace(next)) continue;
92
+ if (m.single && (next === m.marker || text[p - 1] === m.marker)) continue;
93
+ if (m.underscore && isWord(text[p - 1])) continue;
94
+ return p;
95
+ }
96
+ return -1;
97
+ }
98
+ function findCloser(text, m, from) {
99
+ for (let p = text.indexOf(m.marker, from); p !== -1; p = text.indexOf(m.marker, p + 1)) {
100
+ if (p === from) continue;
101
+ const prev = text[p - 1];
102
+ if (prev === void 0 || isSpace(prev)) continue;
103
+ if (m.single && (prev === m.marker || text[p + m.marker.length] === m.marker))
104
+ continue;
105
+ if (m.underscore && isWord(text[p + m.marker.length])) continue;
106
+ return p;
107
+ }
108
+ return -1;
109
+ }
110
+ function renderMarks(text, level, autoClose) {
111
+ const m = MARKS[level];
112
+ if (m === void 0) return escapeHtml(text);
113
+ let out = "";
114
+ let rest = text;
115
+ for (; ; ) {
116
+ const open = findOpener(rest, m, 0);
117
+ if (open === -1) return out + renderMarks(rest, level + 1, autoClose);
118
+ const close = findCloser(rest, m, open + m.marker.length);
119
+ if (close === -1) {
120
+ const tail = rest.slice(open + m.marker.length);
121
+ if (autoClose && tail.length > 0) {
122
+ return out + renderMarks(rest.slice(0, open), level + 1, false) + `<${m.tag}>${renderMarks(tail, level + 1, false)}</${m.tag}>`;
123
+ }
124
+ out += renderMarks(
125
+ rest.slice(0, open + m.marker.length),
126
+ level + 1,
127
+ false
128
+ );
129
+ rest = tail;
130
+ continue;
131
+ }
132
+ out += renderMarks(rest.slice(0, open), level + 1, false) + `<${m.tag}>${renderMarks(
133
+ rest.slice(open + m.marker.length, close),
134
+ level + 1,
135
+ false
136
+ )}</${m.tag}>`;
137
+ rest = rest.slice(close + m.marker.length);
138
+ }
139
+ }
140
+ var URL_RE = /^https?:\/\/[^\s()]*$/;
141
+ function matchLink(text) {
142
+ let lb = text.indexOf("[");
143
+ while (lb !== -1) {
144
+ const rb = text.indexOf("]", lb + 1);
145
+ if (rb === -1) return null;
146
+ if (text[rb + 1] === "(") {
147
+ const rp = text.indexOf(")", rb + 2);
148
+ if (rp === -1) return null;
149
+ const url = text.slice(rb + 2, rp);
150
+ if (URL_RE.test(url)) {
151
+ return {
152
+ index: lb,
153
+ length: rp + 1 - lb,
154
+ label: text.slice(lb + 1, rb),
155
+ url
156
+ };
157
+ }
158
+ }
159
+ lb = text.indexOf("[", lb + 1);
160
+ }
161
+ return null;
162
+ }
163
+ function renderLinks(text, autoClose) {
164
+ let out = "";
165
+ let rest = text;
166
+ for (; ; ) {
167
+ const m = matchLink(rest);
168
+ if (m === null) return out + renderMarks(rest, 0, autoClose);
169
+ out += renderMarks(rest.slice(0, m.index), 0, false) + `<a href="${escapeAttr(m.url)}">${renderMarks(m.label, 0, false)}</a>`;
170
+ rest = rest.slice(m.index + m.length);
171
+ }
172
+ }
173
+ function renderCode(text, autoClose) {
174
+ let out = "";
175
+ let rest = text;
176
+ for (; ; ) {
177
+ const open = rest.indexOf("`");
178
+ if (open === -1) return out + renderLinks(rest, autoClose);
179
+ const close = rest.indexOf("`", open + 1);
180
+ if (close === -1) {
181
+ const inner = rest.slice(open + 1);
182
+ if (autoClose && inner.length > 0) {
183
+ return out + renderLinks(rest.slice(0, open), false) + `<code>${escapeHtml(inner)}</code>`;
184
+ }
185
+ return out + renderLinks(rest, autoClose);
186
+ }
187
+ if (close === open + 1) {
188
+ out += renderLinks(rest.slice(0, close + 1), false);
189
+ rest = rest.slice(close + 1);
190
+ continue;
191
+ }
192
+ out += renderLinks(rest.slice(0, open), false) + `<code>${escapeHtml(rest.slice(open + 1, close))}</code>`;
193
+ rest = rest.slice(close + 1);
194
+ }
195
+ }
196
+ function renderInline(line, autoClose) {
197
+ return renderCode(line, autoClose);
198
+ }
199
+ var FENCE_OPEN_RE = /^(`{3,}|~{3,})\s*(.*)$/;
200
+ var TICK_CLOSE_RE = /^`{3,}\s*$/;
201
+ var TILDE_CLOSE_RE = /^~{3,}\s*$/;
202
+ var LANG_RE = /^[A-Za-z0-9_+-]+$/;
203
+ function fenceLang(info) {
204
+ const token = info.trim().split(/\s+/)[0] ?? "";
205
+ return token !== "" && LANG_RE.test(token) ? token : null;
206
+ }
207
+ function renderFence(body, lang) {
208
+ const code = escapeHtml(body);
209
+ return lang === null ? `<pre>${code}</pre>` : `<pre><code class="language-${lang}">${code}</code></pre>`;
210
+ }
211
+ var CODE_SPAN_RE = /<code>[\s\S]*?<\/code>/g;
212
+ function boldHeading(html) {
213
+ let out = "";
214
+ let last = 0;
215
+ for (const m of html.matchAll(CODE_SPAN_RE)) {
216
+ const before = html.slice(last, m.index);
217
+ if (before !== "") out += `<b>${before}</b>`;
218
+ out += m[0];
219
+ last = m.index + m[0].length;
220
+ }
221
+ const tail = html.slice(last);
222
+ if (tail !== "") out += `<b>${tail}</b>`;
223
+ return out;
224
+ }
225
+ function mdToTelegramHtml(md, opts = {}) {
226
+ const partial = opts.partial === true;
227
+ const lines = md.split("\n");
228
+ const out = [];
229
+ let i = 0;
230
+ while (i < lines.length) {
231
+ const line = lines[i] ?? "";
232
+ const fence = FENCE_OPEN_RE.exec(line);
233
+ if (fence !== null) {
234
+ const closeRe = (fence[1] ?? "").startsWith("`") ? TICK_CLOSE_RE : TILDE_CLOSE_RE;
235
+ const lang = fenceLang(fence[2] ?? "");
236
+ let close = -1;
237
+ for (let j = i + 1; j < lines.length; j += 1) {
238
+ if (closeRe.test(lines[j] ?? "")) {
239
+ close = j;
240
+ break;
241
+ }
242
+ }
243
+ if (close !== -1) {
244
+ out.push(renderFence(lines.slice(i + 1, close).join("\n"), lang));
245
+ i = close + 1;
246
+ } else if (partial) {
247
+ const body = lines.slice(i + 1).join("\n");
248
+ out.push(body.length > 0 ? renderFence(body, lang) : escapeHtml(line));
249
+ i = lines.length;
250
+ } else {
251
+ out.push(escapeHtml(lines.slice(i).join("\n")));
252
+ i = lines.length;
253
+ }
254
+ continue;
255
+ }
256
+ if (/^>\s?/.test(line)) {
257
+ const quoted = [];
258
+ let j = i;
259
+ while (j < lines.length && /^>\s?/.test(lines[j] ?? "")) {
260
+ quoted.push((lines[j] ?? "").replace(/^>\s?/, ""));
261
+ j += 1;
262
+ }
263
+ const lastQuoted = partial && j === lines.length;
264
+ const inner = quoted.map((q, k) => renderInline(q, lastQuoted && k === quoted.length - 1)).join("\n");
265
+ out.push(
266
+ inner.trim() === "" ? escapeHtml(lines.slice(i, j).join("\n")) : `<blockquote>${inner}</blockquote>`
267
+ );
268
+ i = j;
269
+ continue;
270
+ }
271
+ const autoClose = partial && i === lines.length - 1;
272
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
273
+ if (heading !== null) {
274
+ out.push(boldHeading(renderInline(heading[1] ?? "", autoClose)));
275
+ i += 1;
276
+ continue;
277
+ }
278
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
279
+ if (bullet !== null) {
280
+ out.push(
281
+ `${bullet[1] ?? ""}\u2022 ${renderInline(bullet[2] ?? "", autoClose)}`
282
+ );
283
+ i += 1;
284
+ continue;
285
+ }
286
+ out.push(renderInline(line, autoClose));
287
+ i += 1;
288
+ }
289
+ return out.join("\n");
290
+ }
291
+
292
+ // src/format/rich.ts
293
+ var TABLE_DELIM_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
294
+ var isPipeLine = (line) => line.includes("|");
295
+ var isTableRowLine = (line) => /^\s*\|.*\|\s*$/.test(line);
296
+ function isTableBlock(lines) {
297
+ return lines.length >= 2 && isPipeLine(lines[0] ?? "") && TABLE_DELIM_RE.test(lines[1] ?? "");
298
+ }
299
+ function isPipeParagraph(lines) {
300
+ return lines.length >= 1 && lines.every(isTableRowLine) && !isTableBlock(lines);
301
+ }
302
+ function endsAsTable(lines) {
303
+ return isTableBlock(lines) && lines.slice(2).every(isTableRowLine);
304
+ }
305
+ var FENCE_LINE_RE = /^ {0,3}(`{3,}|~{3,})(.*)/;
306
+ var HTML_CODE_TAG_RE = /<(pre|code)(?:\s[^>]*)?>|<\/(pre|code)>/gi;
307
+ function advanceHtmlLine(open, line) {
308
+ let cur = open;
309
+ for (const m of line.matchAll(HTML_CODE_TAG_RE)) {
310
+ const closing = m[0][1] === "/";
311
+ const tag = (m[1] ?? m[2])?.toLowerCase() === "pre" ? "pre" : "code";
312
+ if (cur === null) {
313
+ if (!closing) cur = { kind: "html", tag };
314
+ } else if (cur.kind === "html" && closing && tag === cur.tag) {
315
+ cur = null;
316
+ }
317
+ }
318
+ return cur;
319
+ }
320
+ function advanceCodeRegion(open, lines) {
321
+ let cur = open;
322
+ for (const line of lines) {
323
+ if (cur?.kind === "html") {
324
+ cur = advanceHtmlLine(cur, line);
325
+ continue;
326
+ }
327
+ const m = FENCE_LINE_RE.exec(line);
328
+ if (cur?.kind === "fence") {
329
+ if (m !== null) {
330
+ const run = m[1] ?? "";
331
+ if ((run[0] ?? "") === cur.char && run.length >= cur.len && (m[2] ?? "").trim() === "")
332
+ cur = null;
333
+ }
334
+ continue;
335
+ }
336
+ if (m !== null) {
337
+ const run = m[1] ?? "";
338
+ const info = m[2] ?? "";
339
+ const char = run[0] ?? "";
340
+ if (char === "`" && info.includes("`")) {
341
+ cur = advanceHtmlLine(null, line);
342
+ } else {
343
+ cur = { kind: "fence", char, len: run.length };
344
+ }
345
+ continue;
346
+ }
347
+ cur = advanceHtmlLine(null, line);
348
+ }
349
+ return cur;
350
+ }
351
+ function repairRichTables(md) {
352
+ if (!md.includes("|")) return md;
353
+ const out = [];
354
+ let region = null;
355
+ let prevTable = false;
356
+ for (const block of md.split("\n\n")) {
357
+ const lines = block.split("\n");
358
+ const insideCode = region !== null;
359
+ const prev = out[out.length - 1];
360
+ if (!insideCode && prev !== void 0 && prevTable && isPipeParagraph(lines)) {
361
+ out[out.length - 1] = `${prev}
362
+ ${block}`;
363
+ } else {
364
+ out.push(block);
365
+ prevTable = !insideCode && endsAsTable(lines);
366
+ }
367
+ region = advanceCodeRegion(region, lines);
368
+ }
369
+ return out.join("\n\n");
370
+ }
371
+ var TRAILING_COVER_RE = /^!\[[^\]]*\]\(\s*(https?:\/\/[^\s)]+)[^)]*\)$/;
372
+ function insideCodeRegion(lines, idx) {
373
+ return advanceCodeRegion(null, lines.slice(0, idx)) !== null;
374
+ }
375
+ function extractTrailingCover(md) {
376
+ let end = md.length - 1;
377
+ while (end >= 0 && /\s/.test(md[end] ?? "")) end--;
378
+ if (end < 0 || md[end] !== ")") return null;
379
+ const lines = md.split("\n");
380
+ let i = lines.length - 1;
381
+ while (i >= 0 && (lines[i] ?? "").trim() === "") i--;
382
+ if (i < 0) return null;
383
+ const m = TRAILING_COVER_RE.exec((lines[i] ?? "").trim());
384
+ if (m === null) return null;
385
+ if (insideCodeRegion(lines, i)) return null;
386
+ const url = m[1] ?? "";
387
+ const body = lines.slice(0, i).join("\n").replace(/\s+$/, "");
388
+ return { url, body };
389
+ }
390
+ var RICH_MEDIA_RE = new RegExp(
391
+ [
392
+ // fenced code block (```+) — line-anchored open; close on a ≥open run that
393
+ // owns its own line, else code through EOF.
394
+ "(?:^|\\n)[ ]{0,3}(`{3,})[^\\n`]*(?:[\\s\\S]*?\\n[ ]{0,3}\\1`*[ \\t]*(?=\\n|$)|[\\s\\S]*)",
395
+ // fenced code block (~~~+) — same, tilde info strings may contain backticks.
396
+ "(?:^|\\n)[ ]{0,3}(~{3,})[^\\n]*(?:[\\s\\S]*?\\n[ ]{0,3}\\2~*[ \\t]*(?=\\n|$)|[\\s\\S]*)",
397
+ // inline code span — both delimiters must be COMPLETE backtick runs of EQUAL
398
+ // length (CommonMark/GFM): opener `(?<!`)(`+)(?!`)` is a whole run, close
399
+ // `(?<!`)\3(?!`)` is a same-length whole run — never a prefix/suffix of a
400
+ // longer one. So a `` … `` span keeps an inner single ` and its nested media
401
+ // literal, while a stray ` adjacent to a longer run is not a span and its real
402
+ // media still neutralizes.
403
+ "(?<!`)(`+)(?!`)[\\s\\S]*?(?<!`)\\3(?!`)",
404
+ // HTML code regions — Rich Markdown honors Telegram's Rich HTML tag subset
405
+ // (docs: "can contain arbitrary HTML; supported tags are parsed as in Rich
406
+ // HTML style"), in which <pre> (block) and <code> (inline) are fixed-width
407
+ // code: their body is literal, so a nested ![](…) is NOT a media send and
408
+ // must stay verbatim — same rationale as the backtick/tilde branches. Both
409
+ // are NON-capturing so the media groups stay 4/5/6. Lazy to the first close;
410
+ // <pre> is listed before <code> so a <pre><code>…</code></pre> is consumed as
411
+ // ONE region. Optional attributes (e.g. <code class="language-ts">) are
412
+ // allowed, and the `(?:\s[^>]*)?` guard keeps <codex>/<press> from matching.
413
+ // Only CLOSED regions skip: an unterminated <pre>/<code> is left to
414
+ // neutralize (the reply-safe default — and CommonMark would not treat a raw
415
+ // open tag as code through EOF either, unlike a fence).
416
+ "<pre(?:\\s[^>]*)?>[\\s\\S]*?</pre>",
417
+ "<code(?:\\s[^>]*)?>[\\s\\S]*?</code>",
418
+ // backslash escaping, parity-aware: consume escaped-backslash PAIRS first so
419
+ // only an ODD run of backslashes escapes the `!`. `\![…]` (1 backslash) is a
420
+ // literal `!`; `\\![…]` (2 backslashes = one literal `\`) leaves the `!`
421
+ // unescaped, so the pair is skipped and the real media still neutralizes.
422
+ "\\\\\\\\",
423
+ // an escaped-backslash pair — skipped so it can't falsely escape a following `!`
424
+ "\\\\!",
425
+ // a single backslash + `!` — a genuinely escaped `!`, not a media start
426
+ "!\\[([^\\]]*)\\]\\(\\s*(https?:\\/\\/[^\\s)]+)([^)]*)\\)"
427
+ // media image (groups 4,5,6)
428
+ ].join("|"),
429
+ "g"
430
+ );
431
+ function neutralizeRichMedia(md) {
432
+ return md.replace(
433
+ RICH_MEDIA_RE,
434
+ (match, _btFence, _tildeFence, _inlineOpen, label, url, rest) => {
435
+ if (url === void 0) return match;
436
+ return label ? `[${label}](${url}${rest})` : url;
437
+ }
438
+ );
439
+ }
440
+
441
+ // src/bridge/send.ts
442
+ var CAPTION_LIMIT = 1024;
443
+ async function sendClassic(client, chatId, text, signal) {
444
+ for (const chunk of chunkText(text)) {
445
+ let html;
446
+ try {
447
+ html = mdToTelegramHtml(chunk);
448
+ } catch {
449
+ html = null;
450
+ }
451
+ if (html === null) {
452
+ await client.sendMessage({ chatId, text: chunk }, signal);
453
+ continue;
454
+ }
455
+ try {
456
+ await client.sendMessage(
457
+ { chatId, text: html, parseMode: "HTML" },
458
+ signal
459
+ );
460
+ } catch (err) {
461
+ if (!isBadRequest(err)) throw err;
462
+ await client.sendMessage({ chatId, text: chunk }, signal);
463
+ }
464
+ }
465
+ }
466
+ async function sendRich(client, chatId, markdown, log, signal) {
467
+ for (const piece of chunkRich(
468
+ neutralizeRichMedia(repairRichTables(markdown))
469
+ )) {
470
+ try {
471
+ await client.sendRichMessage({ chatId, markdown: piece }, signal);
472
+ } catch (err) {
473
+ if (!isBadRequest(err)) throw err;
474
+ log.warn("telegram rich fallback", {
475
+ method: "sendRichMessage",
476
+ error_code: err.error_code,
477
+ description: err.description,
478
+ chatId
479
+ });
480
+ await sendClassic(client, chatId, piece, signal);
481
+ }
482
+ }
483
+ }
484
+ async function sendText(client, chatId, text, opts, signal) {
485
+ if (opts.rich) await sendRich(client, chatId, text, opts.log, signal);
486
+ else await sendClassic(client, chatId, text, signal);
487
+ }
488
+ async function sendCover(client, chatId, url, caption, signal) {
489
+ if (caption === void 0 || caption === "") {
490
+ await client.sendPhoto({ chatId, url }, signal);
491
+ return;
492
+ }
493
+ let html;
494
+ try {
495
+ html = mdToTelegramHtml(caption);
496
+ } catch {
497
+ html = null;
498
+ }
499
+ if (html === null) {
500
+ await client.sendPhoto({ chatId, url, caption }, signal);
501
+ return;
502
+ }
503
+ try {
504
+ await client.sendPhoto(
505
+ { chatId, url, caption: html, parseMode: "HTML" },
506
+ signal
507
+ );
508
+ } catch (err) {
509
+ if (!isBadRequest(err)) throw err;
510
+ await client.sendPhoto({ chatId, url, caption }, signal);
511
+ }
512
+ }
513
+ async function sendReply(client, chatId, reply, opts, signal) {
514
+ const cover = extractTrailingCover(reply);
515
+ if (cover === null) {
516
+ await sendText(client, chatId, reply, opts, signal);
517
+ return;
518
+ }
519
+ const longBody = cover.body.length > CAPTION_LIMIT;
520
+ try {
521
+ if (longBody) await sendCover(client, chatId, cover.url, void 0, signal);
522
+ else await sendCover(client, chatId, cover.url, cover.body, signal);
523
+ } catch (err) {
524
+ if (!isBadRequest(err)) throw err;
525
+ await sendText(client, chatId, neutralizeRichMedia(reply), opts, signal);
526
+ return;
527
+ }
528
+ if (longBody) await sendText(client, chatId, cover.body, opts, signal);
529
+ }
530
+
531
+ // src/draft/constants.ts
532
+ var DEFAULT_DRAFT_CONSTANTS = {
533
+ throttleMs: 300,
534
+ // min interval between draft content writes
535
+ keepaliveMs: 2e4,
536
+ // re-send last draft text within the ~30s expiry window
537
+ typingHeartbeatMs: 4500,
538
+ // re-send `typing` before its ~5s lifetime lapses
539
+ previewCap: 4e3,
540
+ // one-message cap for the live preview (= CHUNK_TARGET)
541
+ maxFailures: 3,
542
+ // consecutive send failures → disable draft for the turn
543
+ drainMs: 1e3,
544
+ // finalize() wait for an in-flight write before abandoning
545
+ tickMs: 250
546
+ // ticker granularity
547
+ };
548
+
549
+ // src/draft/draft-streamer.ts
550
+ var defaultDelay = (ms) => {
551
+ let handle;
552
+ const promise = new Promise((resolve) => {
553
+ handle = setTimeout(resolve, ms);
554
+ });
555
+ return { promise, cancel: () => clearTimeout(handle) };
556
+ };
557
+ function createDraftStreamer(deps) {
558
+ const k = { ...DEFAULT_DRAFT_CONSTANTS, ...deps.constants };
559
+ const now = deps.now ?? (() => Date.now());
560
+ const schedule = deps.schedule ?? ((fn, ms) => setInterval(fn, ms));
561
+ const cancel = deps.cancel ?? ((h) => clearInterval(h));
562
+ const delay = deps.delay ?? defaultDelay;
563
+ const { client, chatId, draftId, log } = deps;
564
+ let latest = "";
565
+ let lastSent = null;
566
+ let lastSentAt = 0;
567
+ let lastAttemptAt = Number.NEGATIVE_INFINITY;
568
+ let lastTypingAt = 0;
569
+ let inFlight = null;
570
+ let inFlightController = null;
571
+ let consecutiveFailures = 0;
572
+ let disabled = false;
573
+ let richMode = deps.rich;
574
+ let stopped = false;
575
+ let timer = null;
576
+ function send(text, t) {
577
+ const controller = new AbortController();
578
+ inFlightController = controller;
579
+ lastAttemptAt = t;
580
+ const usingRich = richMode;
581
+ const op = usingRich ? client.sendRichMessageDraft(
582
+ { chatId, draftId, markdown: text },
583
+ controller.signal
584
+ ) : client.sendMessageDraft({ chatId, draftId, text }, controller.signal);
585
+ inFlight = op.then(() => {
586
+ lastSent = text;
587
+ lastSentAt = now();
588
+ consecutiveFailures = 0;
589
+ }).catch((err) => {
590
+ if (controller.signal.aborted) return;
591
+ if (usingRich && isBadRequest(err)) {
592
+ richMode = false;
593
+ log.warn("telegram draft rich disabled", {
594
+ chat_id: chatId,
595
+ method: "sendRichMessageDraft",
596
+ error_code: err.error_code,
597
+ description: err.description
598
+ });
599
+ return;
600
+ }
601
+ consecutiveFailures += 1;
602
+ if (consecutiveFailures >= k.maxFailures) {
603
+ disabled = true;
604
+ log.warn("telegram draft disabled after failures", {
605
+ chat_id: chatId,
606
+ failures: consecutiveFailures
607
+ });
608
+ } else {
609
+ log.warn("telegram draft send failed", { chat_id: chatId });
610
+ }
611
+ }).finally(() => {
612
+ inFlight = null;
613
+ inFlightController = null;
614
+ });
615
+ }
616
+ function maybeFlush() {
617
+ if (stopped || disabled || inFlight) return;
618
+ const t = now();
619
+ if (latest !== lastSent && latest.trim().length > 0 && t - lastAttemptAt >= k.throttleMs) {
620
+ send(latest, t);
621
+ } else if (lastSent !== null && t - lastSentAt >= k.keepaliveMs) {
622
+ send(lastSent, t);
623
+ }
624
+ }
625
+ function tick() {
626
+ maybeFlush();
627
+ if (!stopped && now() - lastTypingAt >= k.typingHeartbeatMs) {
628
+ client.sendChatAction({ chatId }).catch(() => {
629
+ });
630
+ lastTypingAt = now();
631
+ }
632
+ }
633
+ return {
634
+ start() {
635
+ lastTypingAt = now();
636
+ client.sendChatAction({ chatId }).catch(() => {
637
+ });
638
+ timer = schedule(tick, k.tickMs);
639
+ },
640
+ push(fullText) {
641
+ latest = safeSlice(fullText, k.previewCap);
642
+ maybeFlush();
643
+ },
644
+ async finalize() {
645
+ try {
646
+ stopped = true;
647
+ if (timer !== null) {
648
+ cancel(timer);
649
+ timer = null;
650
+ }
651
+ if (!inFlight) return;
652
+ const drain = delay(k.drainMs);
653
+ try {
654
+ const landed = await Promise.race([
655
+ inFlight.then(() => true),
656
+ drain.promise.then(() => false)
657
+ ]);
658
+ if (!landed) {
659
+ inFlightController?.abort();
660
+ await inFlight?.catch(() => {
661
+ });
662
+ }
663
+ } finally {
664
+ drain.cancel();
665
+ }
666
+ } catch (err) {
667
+ log.warn("telegram draft finalize error", { err: String(err) });
668
+ }
669
+ },
670
+ async abort() {
671
+ try {
672
+ stopped = true;
673
+ if (timer !== null) {
674
+ cancel(timer);
675
+ timer = null;
676
+ }
677
+ inFlightController?.abort();
678
+ await inFlight?.catch(() => {
679
+ });
680
+ } catch (err) {
681
+ log.warn("telegram draft abort error", { err: String(err) });
682
+ }
683
+ }
684
+ };
685
+ }
686
+
687
+ // src/bridge/turn-loop.ts
688
+ var NOOP_LOG = { warn: () => {
689
+ }, error: () => {
690
+ } };
691
+ async function runTelegramTurn(opts) {
692
+ const now = opts.now ?? (() => Date.now());
693
+ const log = opts.log ?? NOOP_LOG;
694
+ const makeDraftStreamer = opts.makeDraftStreamer ?? createDraftStreamer;
695
+ const ctx = { chatKey: opts.chatKey, userText: opts.userText };
696
+ let rollback = null;
697
+ let turnCompleted = false;
698
+ let draft = null;
699
+ let draftTornDown = false;
700
+ try {
701
+ if (opts.hooks?.beforeTurn) {
702
+ try {
703
+ await opts.hooks.beforeTurn(ctx);
704
+ } catch (err) {
705
+ log.error("telegram beforeTurn hook failed", { err: String(err) });
706
+ }
707
+ }
708
+ if (opts.hooks?.preStream) {
709
+ let res;
710
+ try {
711
+ res = await opts.hooks.preStream(ctx) ?? void 0;
712
+ } catch (err) {
713
+ log.error("telegram preStream hook failed", { err: String(err) });
714
+ res = void 0;
715
+ }
716
+ if (res?.skip) return;
717
+ }
718
+ const threadId = await opts.threadStore.resolve(opts.chatKey, now());
719
+ const checkpointId = await opts.checkpointer.snapshot(threadId);
720
+ rollback = { threadId, checkpointId };
721
+ draft = makeDraftStreamer({
722
+ client: opts.client,
723
+ chatId: opts.chatKey.chatId,
724
+ draftId: opts.draftId,
725
+ rich: opts.rich,
726
+ log,
727
+ constants: opts.draftConstants
728
+ });
729
+ draft.start();
730
+ let reply = "";
731
+ let errored = false;
732
+ for await (const ev of opts.agentStream(
733
+ { messages: [{ role: "user", content: opts.userText }] },
734
+ { threadId, signal: opts.signal }
735
+ )) {
736
+ if (ev.type === "token") {
737
+ reply += ev.text;
738
+ draft.push(reply);
739
+ } else if (ev.type === "error") errored = true;
740
+ }
741
+ if (errored) {
742
+ draftTornDown = true;
743
+ await draft.abort().catch(() => {
744
+ });
745
+ await opts.checkpointer.rollback(rollback.threadId, rollback.checkpointId).catch(
746
+ (e) => log.error("telegram rollback failed", { err: String(e) })
747
+ );
748
+ return;
749
+ }
750
+ draftTornDown = true;
751
+ await draft.finalize().catch(() => {
752
+ });
753
+ turnCompleted = true;
754
+ if (reply.trim().length > 0) {
755
+ await sendReply(
756
+ opts.client,
757
+ opts.chatKey.chatId,
758
+ reply,
759
+ { rich: opts.rich, log },
760
+ opts.signal
761
+ );
762
+ } else {
763
+ log.warn("telegram empty reply", { chatId: opts.chatKey.chatId });
764
+ }
765
+ await opts.threadStore.touch(opts.chatKey, now());
766
+ } catch (err) {
767
+ log.error("telegram turn failed", { err: String(err) });
768
+ if (rollback && !turnCompleted) {
769
+ draftTornDown = true;
770
+ await draft?.abort().catch(() => {
771
+ });
772
+ await opts.checkpointer.rollback(rollback.threadId, rollback.checkpointId).catch(
773
+ (e) => log.error("telegram rollback failed", { err: String(e) })
774
+ );
775
+ }
776
+ } finally {
777
+ if (!draftTornDown) await draft?.finalize().catch(() => {
778
+ });
779
+ if (opts.hooks?.afterTurn) {
780
+ try {
781
+ await opts.hooks.afterTurn(ctx);
782
+ } catch (err) {
783
+ log.error("telegram afterTurn hook failed", { err: String(err) });
784
+ }
785
+ }
786
+ }
787
+ }
788
+ export {
789
+ DEFAULT_DRAFT_CONSTANTS,
790
+ TelegramApiError,
791
+ chunkRich,
792
+ chunkText,
793
+ createDraftStreamer,
794
+ extractTrailingCover,
795
+ isBadRequest,
796
+ mdToTelegramHtml,
797
+ neutralizeRichMedia,
798
+ repairRichTables,
799
+ runTelegramTurn,
800
+ safeSlice,
801
+ sendReply,
802
+ sendText
803
+ };