@copilotkit/channels-telegram 0.0.3

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/adapter.d.ts +117 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +584 -0
  6. package/dist/built-in-context.d.ts +22 -0
  7. package/dist/built-in-context.d.ts.map +1 -0
  8. package/dist/built-in-context.js +77 -0
  9. package/dist/built-in-tools.d.ts +23 -0
  10. package/dist/built-in-tools.d.ts.map +1 -0
  11. package/dist/built-in-tools.js +46 -0
  12. package/dist/chunked-edit-stream.d.ts +70 -0
  13. package/dist/chunked-edit-stream.d.ts.map +1 -0
  14. package/dist/chunked-edit-stream.js +197 -0
  15. package/dist/conversation-store.d.ts +56 -0
  16. package/dist/conversation-store.d.ts.map +1 -0
  17. package/dist/conversation-store.js +98 -0
  18. package/dist/download-files.d.ts +68 -0
  19. package/dist/download-files.d.ts.map +1 -0
  20. package/dist/download-files.js +157 -0
  21. package/dist/event-renderer.d.ts +39 -0
  22. package/dist/event-renderer.d.ts.map +1 -0
  23. package/dist/event-renderer.js +295 -0
  24. package/dist/format-fallback.d.ts +6 -0
  25. package/dist/format-fallback.d.ts.map +1 -0
  26. package/dist/format-fallback.js +21 -0
  27. package/dist/index.d.ts +20 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/interaction.d.ts +65 -0
  31. package/dist/interaction.d.ts.map +1 -0
  32. package/dist/interaction.js +159 -0
  33. package/dist/listener.d.ts +16 -0
  34. package/dist/listener.d.ts.map +1 -0
  35. package/dist/listener.js +419 -0
  36. package/dist/render/budget.d.ts +18 -0
  37. package/dist/render/budget.d.ts.map +1 -0
  38. package/dist/render/budget.js +24 -0
  39. package/dist/render/telegram.d.ts +11 -0
  40. package/dist/render/telegram.d.ts.map +1 -0
  41. package/dist/render/telegram.js +429 -0
  42. package/dist/telegram-html.d.ts +22 -0
  43. package/dist/telegram-html.d.ts.map +1 -0
  44. package/dist/telegram-html.js +98 -0
  45. package/dist/types.d.ts +83 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +2 -0
  48. package/package.json +57 -0
@@ -0,0 +1,429 @@
1
+ import { telegramHtml, escapeHtml } from "../telegram-html.js";
2
+ import { TELEGRAM_LIMITS, truncateText, clampArray, byteLen, } from "./budget.js";
3
+ /**
4
+ * Render a cross-platform component IR tree into a Telegram Bot API payload.
5
+ *
6
+ * The renderer is total: unknown intrinsic types are skipped rather than
7
+ * throwing. Telegram limits are enforced via {@link truncateText},
8
+ * {@link clampArray}, and {@link byteLen}.
9
+ */
10
+ export function renderTelegram(ir) {
11
+ // Each entry holds the pre-HTML (raw) text and the corresponding HTML for a
12
+ // line. Separating them lets us apply the character budget to SOURCE text
13
+ // before HTML conversion, so the emitted HTML is always well-formed.
14
+ const lineEntries = [];
15
+ const inlineKeyboard = [];
16
+ const photos = [];
17
+ for (const node of ir) {
18
+ renderNode(node, lineEntries, inlineKeyboard, photos);
19
+ }
20
+ // Build the HTML, enforcing the message-text budget on the joined RAW text
21
+ // (before HTML conversion) so the emitted HTML is always well-formed.
22
+ //
23
+ // Bug 2: the budget is measured in RAW chars, but the emitted payload is
24
+ // HTML — `<b>`, `<a href>`, and `&amp;`/`&lt;` entity expansion add length
25
+ // that the raw budget does not count. A near-4096 raw message with heavy
26
+ // markup can therefore exceed Telegram's 4096-char hard limit once converted
27
+ // ("message is too long", not caught by the format fallback). We bound the
28
+ // raw text first, then, if the resulting HTML still exceeds the limit,
29
+ // iteratively shrink the raw budget until the HTML fits — and apply a final
30
+ // hard guard that slices the HTML at a safe tag/entity boundary if needed.
31
+ const rawJoined = lineEntries.map((e) => e.raw).join("\n");
32
+ const LIMIT = TELEGRAM_LIMITS.messageText;
33
+ let rawBudget = LIMIT;
34
+ let text = buildBoundedHtml(lineEntries, rawJoined, rawBudget);
35
+ // Iteratively reduce the raw budget while the HTML overshoots the limit. Each
36
+ // pass cuts the budget proportionally to the observed overshoot (plus a small
37
+ // constant) so we converge quickly even under ~5x entity expansion.
38
+ let guard = 0;
39
+ while (text.length > LIMIT && rawBudget > 0 && guard < 32) {
40
+ const overshoot = text.length - LIMIT;
41
+ // Shrink by at least the overshoot (markup is at least 1:1) plus headroom.
42
+ rawBudget = Math.max(0, rawBudget - overshoot - 16);
43
+ text = buildBoundedHtml(lineEntries, rawJoined, rawBudget);
44
+ guard++;
45
+ }
46
+ // Final hard guard: if the HTML is still over (pathological markup density),
47
+ // slice it at a safe boundary that never splits a tag or an entity.
48
+ if (text.length > LIMIT) {
49
+ text = sliceHtmlSafely(text, LIMIT);
50
+ }
51
+ const result = { text, parseMode: "HTML" };
52
+ if (inlineKeyboard.length > 0)
53
+ result.inlineKeyboard = inlineKeyboard;
54
+ if (photos.length > 0)
55
+ result.photos = photos;
56
+ return result;
57
+ }
58
+ /**
59
+ * Build the joined HTML for `lineEntries`, enforcing a raw-character budget on
60
+ * the source text. Full lines reuse their pre-computed html; the last
61
+ * (possibly truncated) line is re-rendered from its raw slice. Returns
62
+ * well-formed HTML (no split tag/entity) whose underlying raw content is
63
+ * ≤ `rawBudget` chars.
64
+ */
65
+ function buildBoundedHtml(lineEntries, rawJoined, rawBudget) {
66
+ const rawBounded = truncateText(rawJoined, rawBudget);
67
+ const wasTruncated = rawBounded.length !== rawJoined.length;
68
+ if (!wasTruncated) {
69
+ // Nothing was truncated — use the pre-computed HTML directly.
70
+ return lineEntries.map((e) => e.html).join("\n");
71
+ }
72
+ // Some lines were cut. `rawBounded` ends with a trailing "…" marker that
73
+ // truncateText appended (occupying one char of the budget but NOT present in
74
+ // the source entries). Strip it to recover the exact char budget of real
75
+ // content, walk the entries against that budget, then re-append "…" to the
76
+ // final emitted slice so the marker is preserved and the reconstructed raw
77
+ // length stays within budget.
78
+ const ELLIPSIS = "…";
79
+ const contentBudget = rawBounded.endsWith(ELLIPSIS)
80
+ ? rawBounded.length - 1
81
+ : rawBounded.length;
82
+ const htmlParts = [];
83
+ let remaining = contentBudget;
84
+ let markerEmitted = false;
85
+ for (const entry of lineEntries) {
86
+ if (remaining <= 0)
87
+ break;
88
+ if (entry.raw.length <= remaining) {
89
+ htmlParts.push(entry.html);
90
+ remaining -= entry.raw.length;
91
+ if (remaining > 0)
92
+ remaining -= 1; // account for the "\n" separator
93
+ }
94
+ else {
95
+ // This entry is partially truncated — re-render the bounded raw slice
96
+ // and append the ellipsis marker so dropped content is signalled.
97
+ const slicedRaw = entry.raw.slice(0, remaining);
98
+ htmlParts.push(reRenderRawLine(slicedRaw, entry) + ELLIPSIS);
99
+ markerEmitted = true;
100
+ break;
101
+ }
102
+ }
103
+ // If the cut fell exactly on a line boundary (no partial line was emitted),
104
+ // the marker would otherwise be lost. Append it to the last emitted line.
105
+ if (!markerEmitted && htmlParts.length > 0) {
106
+ htmlParts[htmlParts.length - 1] += ELLIPSIS;
107
+ }
108
+ return htmlParts.join("\n");
109
+ }
110
+ /**
111
+ * Re-render a truncated raw slice using the structural wrapper the original
112
+ * node recorded on the entry (Bug 3). Using the stored {@link WrapKind} —
113
+ * rather than reverse-engineering it from the html via regex — guarantees the
114
+ * truncated slice is re-wrapped balanced and correctly styled, even for lines
115
+ * whose html is NOT a single top-level wrapper (e.g. `<i>a</i> b <i>c</i>` or
116
+ * a `<b>Label</b> value` field).
117
+ */
118
+ function reRenderRawLine(slicedRaw, entry) {
119
+ switch (entry.wrap) {
120
+ case "pre":
121
+ // Table/preformatted block: the slice is plain text — escape it and wrap
122
+ // in a balanced <pre>. (telegramHtml would re-interpret markdown that the
123
+ // original grid never had.)
124
+ return `<pre>${escapeHtml(slicedRaw)}</pre>`;
125
+ case "code":
126
+ return `<code>${escapeHtml(slicedRaw)}</code>`;
127
+ case "b":
128
+ // Header: original html was `<b>${escapeHtml(raw)}</b>`. Re-escape the
129
+ // slice (NOT telegramHtml — headers are plain text, not markdown).
130
+ return `<b>${escapeHtml(slicedRaw)}</b>`;
131
+ case "i":
132
+ // Context/input: original html was `<i>${telegramHtml(raw)}</i>`.
133
+ return `<i>${telegramHtml(slicedRaw)}</i>`;
134
+ case "none":
135
+ case undefined:
136
+ default:
137
+ // Inline telegramHtml output (section/markdown/text/fields/raw/divider) —
138
+ // the html is already balanced inline markup; re-run telegramHtml on the
139
+ // slice for the same well-formed result.
140
+ return telegramHtml(slicedRaw);
141
+ }
142
+ }
143
+ /**
144
+ * Hard guard for the pathological case where, even after shrinking the raw
145
+ * budget, the HTML still exceeds `limit` (extreme markup density). Slice the
146
+ * HTML to at most `limit` chars at a boundary that never splits an open tag or
147
+ * an `&entity;` — by trimming back to before the last unterminated `<` or `&`.
148
+ * Note: this is a last-resort safety net; balanced-tag invariants are upheld by
149
+ * the raw-budget path above, so in practice it is rarely hit.
150
+ */
151
+ function sliceHtmlSafely(html, limit) {
152
+ if (html.length <= limit)
153
+ return html;
154
+ let cut = html.slice(0, limit);
155
+ // If we cut inside an unterminated tag (`…<b` / `…<`), trim back to before
156
+ // the `<`.
157
+ const lastLt = cut.lastIndexOf("<");
158
+ const lastGt = cut.lastIndexOf(">");
159
+ if (lastLt > lastGt)
160
+ cut = cut.slice(0, lastLt);
161
+ // If we cut inside an unterminated entity (`…&am` / `…&`), trim back to
162
+ // before the `&`.
163
+ const lastAmp = cut.lastIndexOf("&");
164
+ const lastSemi = cut.lastIndexOf(";");
165
+ if (lastAmp > lastSemi)
166
+ cut = cut.slice(0, lastAmp);
167
+ return cut;
168
+ }
169
+ /** Render a single IR node, appending to lineEntries/inlineKeyboard/photos. */
170
+ function renderNode(node, lineEntries, inlineKeyboard, photos) {
171
+ if (typeof node.type !== "string")
172
+ return; // non-intrinsic — skip silently
173
+ const props = node.props ?? {};
174
+ /**
175
+ * Push a line, recording the structural wrapper (Bug 3) so a truncated last
176
+ * line can be re-wrapped deterministically. `wrap` defaults to "none" for
177
+ * lines whose html is inline telegramHtml output.
178
+ */
179
+ const pushLine = (raw, html, wrap = "none") => lineEntries.push({ raw, html, wrap });
180
+ /** Push an inline-html line (no structural wrapper). */
181
+ const pushRaw = (raw, html) => pushLine(raw, html, "none");
182
+ switch (node.type) {
183
+ case "message": {
184
+ // Container — flatten children.
185
+ for (const child of childNodes(node)) {
186
+ renderNode(child, lineEntries, inlineKeyboard, photos);
187
+ }
188
+ return;
189
+ }
190
+ case "header": {
191
+ const raw = truncateText(collectText(node), 256);
192
+ pushLine(raw, `<b>${escapeHtml(raw)}</b>`, "b");
193
+ return;
194
+ }
195
+ case "section":
196
+ case "markdown": {
197
+ const raw = collectText(node);
198
+ pushRaw(raw, telegramHtml(raw));
199
+ return;
200
+ }
201
+ case "text": {
202
+ // collectText on a text node returns props.value directly.
203
+ const raw = collectText(node);
204
+ pushRaw(raw, telegramHtml(raw));
205
+ return;
206
+ }
207
+ case "fields": {
208
+ const fieldChildren = childNodes(node).filter((c) => c.type === "field");
209
+ for (const f of fieldChildren) {
210
+ const fieldProps = f.props ?? {};
211
+ const label = fieldProps.label;
212
+ const children = childNodes(f);
213
+ if (label) {
214
+ // Has a label: <b>label</b> value
215
+ const valueText = collectText(f);
216
+ const raw = `${label} ${valueText}`;
217
+ pushRaw(raw, `<b>${escapeHtml(label)}</b> ${telegramHtml(valueText)}`);
218
+ }
219
+ else if (children.length === 1 &&
220
+ children[0] &&
221
+ children[0].type === "text") {
222
+ // Single text child — bold it.
223
+ const raw = collectText(f);
224
+ pushRaw(raw, `<b>${telegramHtml(raw)}</b>`);
225
+ }
226
+ else {
227
+ const raw = collectText(f);
228
+ pushRaw(raw, telegramHtml(raw));
229
+ }
230
+ }
231
+ return;
232
+ }
233
+ case "field": {
234
+ const fieldProps = props;
235
+ const label = fieldProps.label;
236
+ const children = childNodes(node);
237
+ if (label) {
238
+ const valueText = collectText(node);
239
+ const raw = `${label} ${valueText}`;
240
+ pushRaw(raw, `<b>${escapeHtml(label)}</b> ${telegramHtml(valueText)}`);
241
+ }
242
+ else if (children.length === 1 &&
243
+ children[0] &&
244
+ children[0].type === "text") {
245
+ const raw = collectText(node);
246
+ pushRaw(raw, `<b>${telegramHtml(raw)}</b>`);
247
+ }
248
+ else {
249
+ const raw = collectText(node);
250
+ pushRaw(raw, telegramHtml(raw));
251
+ }
252
+ return;
253
+ }
254
+ case "context": {
255
+ const raw = collectText(node);
256
+ pushLine(raw, `<i>${telegramHtml(raw)}</i>`, "i");
257
+ return;
258
+ }
259
+ case "divider": {
260
+ pushRaw("──────", "──────");
261
+ return;
262
+ }
263
+ case "image": {
264
+ const url = (props.url ?? props.image_url);
265
+ const alt = (props.alt ?? props.altText ?? "");
266
+ if (url) {
267
+ // Bug 3 fix: cap caption to TELEGRAM_LIMITS.caption (1024 chars).
268
+ const caption = alt
269
+ ? truncateText(alt, TELEGRAM_LIMITS.caption)
270
+ : undefined;
271
+ const { items } = clampArray([...photos, { url, caption }], TELEGRAM_LIMITS.photosPerMessage);
272
+ photos.splice(0, photos.length, ...items);
273
+ }
274
+ return;
275
+ }
276
+ case "actions": {
277
+ const buttons = childNodes(node)
278
+ .map(renderActionButton)
279
+ .filter((b) => b !== null);
280
+ // Bug 4 fix: cap against the running total across the whole keyboard, not
281
+ // per-node, so multiple actions/select blocks can't exceed the limit.
282
+ appendButtons(inlineKeyboard, buttons);
283
+ return;
284
+ }
285
+ case "select": {
286
+ const options = props.options ?? [];
287
+ // Bug 2 fix: degrade (skip) options whose callback_data exceeds 64 bytes
288
+ // instead of throwing — keeps the rest of the message intact.
289
+ // Consistency: use JSON.stringify(value) (same as actionIdOf) when no id.
290
+ const buttons = options
291
+ .map((o) => {
292
+ const callbackData = o.id ? o.id : JSON.stringify(o.value);
293
+ if (byteLen(callbackData) > TELEGRAM_LIMITS.callbackData) {
294
+ return null; // degrade: skip this option silently
295
+ }
296
+ return {
297
+ text: truncateText(String(o.label ?? ""), TELEGRAM_LIMITS.buttonText),
298
+ callbackData,
299
+ };
300
+ })
301
+ .filter((b) => b !== null);
302
+ // Bug 4 fix: cap against the running keyboard total (see "actions").
303
+ appendButtons(inlineKeyboard, buttons);
304
+ return;
305
+ }
306
+ case "input": {
307
+ const raw = "(open the chat to type your answer)";
308
+ pushLine(raw, `<i>${escapeHtml(raw)}</i>`, "i");
309
+ return;
310
+ }
311
+ case "table": {
312
+ // Render as monospace <pre> grid.
313
+ const columnsProp = props.columns;
314
+ const rowNodes = childNodes(node).filter((c) => c.type === "row");
315
+ const tableRows = [];
316
+ if (columnsProp && columnsProp.length > 0) {
317
+ tableRows.push(columnsProp.map((c) => c.header));
318
+ }
319
+ for (const rowNode of rowNodes) {
320
+ const cells = childNodes(rowNode).filter((c) => c.type === "cell");
321
+ tableRows.push(cells.map((cell) => collectText(cell)));
322
+ }
323
+ if (tableRows.length > 0) {
324
+ // Compute column widths.
325
+ const colCount = Math.max(...tableRows.map((r) => r.length));
326
+ const colWidths = Array(colCount).fill(0);
327
+ for (const row of tableRows) {
328
+ for (let i = 0; i < row.length; i++) {
329
+ colWidths[i] = Math.max(colWidths[i] ?? 0, (row[i] ?? "").length);
330
+ }
331
+ }
332
+ const gridLines = tableRows.map((row) => row.map((cell, i) => cell.padEnd(colWidths[i] ?? 0)).join(" "));
333
+ const rawGrid = gridLines.join("\n");
334
+ pushLine(rawGrid, `<pre>${escapeHtml(rawGrid)}</pre>`, "pre");
335
+ }
336
+ return;
337
+ }
338
+ case "raw": {
339
+ const value = props.value;
340
+ if (value !== null &&
341
+ typeof value === "object" &&
342
+ "text" in value) {
343
+ const rawText = value.text;
344
+ if (typeof rawText === "string") {
345
+ pushRaw(rawText, telegramHtml(rawText));
346
+ return;
347
+ }
348
+ }
349
+ const rawStr = JSON.stringify(value);
350
+ pushRaw(rawStr, telegramHtml(rawStr));
351
+ return;
352
+ }
353
+ default:
354
+ // Unknown intrinsic — skip silently (total renderer).
355
+ return;
356
+ }
357
+ }
358
+ /**
359
+ * Append buttons to the inline keyboard, chunked into rows of buttonsPerRow,
360
+ * while enforcing {@link TELEGRAM_LIMITS.buttonsPerMessage} across the ENTIRE
361
+ * keyboard (Bug 4). Counts buttons already present from prior actions/select
362
+ * nodes and silently drops any overflow rather than throwing — Telegram would
363
+ * otherwise reject the whole message.
364
+ */
365
+ function appendButtons(inlineKeyboard, buttons) {
366
+ const alreadyAdded = inlineKeyboard.reduce((sum, row) => sum + row.length, 0);
367
+ const capacity = TELEGRAM_LIMITS.buttonsPerMessage - alreadyAdded;
368
+ if (capacity <= 0)
369
+ return;
370
+ const accepted = buttons.slice(0, capacity);
371
+ for (let i = 0; i < accepted.length; i += TELEGRAM_LIMITS.buttonsPerRow) {
372
+ inlineKeyboard.push(accepted.slice(i, i + TELEGRAM_LIMITS.buttonsPerRow));
373
+ }
374
+ }
375
+ /** Render one button node for an inline keyboard. Returns null for non-renderable nodes. */
376
+ function renderActionButton(node) {
377
+ if (typeof node.type !== "string")
378
+ return null;
379
+ if (node.type !== "button")
380
+ return null;
381
+ const props = node.props ?? {};
382
+ const buttonText = truncateText(collectText(node), TELEGRAM_LIMITS.buttonText);
383
+ if (props.url) {
384
+ return { text: buttonText, url: props.url };
385
+ }
386
+ const id = actionIdOf(props);
387
+ // Bug 2 fix: degrade (skip) buttons whose callback_data exceeds 64 bytes
388
+ // instead of throwing — keeps the rest of the message intact.
389
+ if (byteLen(id) > TELEGRAM_LIMITS.callbackData) {
390
+ return null;
391
+ }
392
+ return { text: buttonText, callbackData: id };
393
+ }
394
+ /** Derive callback data: prefer registry-stamped id, else value, else "action". */
395
+ function actionIdOf(props) {
396
+ const onClick = props.onClick;
397
+ if (onClick != null && "id" in onClick) {
398
+ const id = onClick.id;
399
+ if (typeof id === "string")
400
+ return id;
401
+ }
402
+ if (props.value !== undefined) {
403
+ return JSON.stringify(props.value);
404
+ }
405
+ return "action";
406
+ }
407
+ /** The expanded `children` of an IR node as a `BotNode[]` (empty if none). */
408
+ function childNodes(node) {
409
+ const children = node.props?.children;
410
+ if (Array.isArray(children))
411
+ return children;
412
+ if (children &&
413
+ typeof children === "object" &&
414
+ "type" in children) {
415
+ return [children];
416
+ }
417
+ return [];
418
+ }
419
+ /** Concatenate the `value` of all descendant `text` nodes (depth-first). */
420
+ function collectText(node) {
421
+ if (typeof node.type === "string" && node.type === "text") {
422
+ return String(node.props?.value ?? "");
423
+ }
424
+ let acc = "";
425
+ for (const child of childNodes(node)) {
426
+ acc += collectText(child);
427
+ }
428
+ return acc;
429
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Translate the agent's standard Markdown into Telegram HTML parse mode.
3
+ *
4
+ * Telegram HTML supports only: <b> <i> <u> <s> <a href> <code> <pre> <tg-spoiler>
5
+ * All & < > in text must be entity-escaped.
6
+ *
7
+ * Markdown → Telegram HTML
8
+ * **bold** → <b>bold</b>
9
+ * __bold__ → <b>bold</b>
10
+ * *italic* → <i>italic</i>
11
+ * _italic_ → <i>italic</i>
12
+ * ~~strike~~ → <s>strike</s>
13
+ * # heading → <b>heading</b>
14
+ * [text](url) → <a href="url">text</a>
15
+ * - bullet → • bullet
16
+ * `code` → <code>code</code>
17
+ * ```…``` → <pre>…</pre>
18
+ */
19
+ /** Escape & < > " for use inside Telegram HTML text nodes and attributes. */
20
+ export declare function escapeHtml(s: string): string;
21
+ export declare function telegramHtml(input: string): string;
22
+ //# sourceMappingURL=telegram-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telegram-html.d.ts","sourceRoot":"","sources":["../src/telegram-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAM5C;AAcD,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAmFlD"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Translate the agent's standard Markdown into Telegram HTML parse mode.
3
+ *
4
+ * Telegram HTML supports only: <b> <i> <u> <s> <a href> <code> <pre> <tg-spoiler>
5
+ * All & < > in text must be entity-escaped.
6
+ *
7
+ * Markdown → Telegram HTML
8
+ * **bold** → <b>bold</b>
9
+ * __bold__ → <b>bold</b>
10
+ * *italic* → <i>italic</i>
11
+ * _italic_ → <i>italic</i>
12
+ * ~~strike~~ → <s>strike</s>
13
+ * # heading → <b>heading</b>
14
+ * [text](url) → <a href="url">text</a>
15
+ * - bullet → • bullet
16
+ * `code` → <code>code</code>
17
+ * ```…``` → <pre>…</pre>
18
+ */
19
+ /** Escape & < > " for use inside Telegram HTML text nodes and attributes. */
20
+ export function escapeHtml(s) {
21
+ return s
22
+ .replace(/&/g, "&amp;")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;")
25
+ .replace(/"/g, "&quot;");
26
+ }
27
+ // Unique placeholder that won't appear in normal text.
28
+ // Uses a non-regex-control-char delimiter so ESLint is happy.
29
+ const CODE_PLACEHOLDER_RE = /\u{FFFE}CODE(\d+)\u{FFFE}/gu;
30
+ function codePlaceholder(i) {
31
+ return `￾CODE${i}￾`;
32
+ }
33
+ const LINK_PLACEHOLDER_RE = /\u{FFFE}LINK(\d+)\u{FFFE}/gu;
34
+ function linkPlaceholder(i) {
35
+ return `￾LINK${i}￾`;
36
+ }
37
+ export function telegramHtml(input) {
38
+ if (!input)
39
+ return input;
40
+ // ── 1. Pull code regions out so we don't touch them. ──
41
+ const codeRegions = [];
42
+ // Fenced code blocks first (```…```)
43
+ let body = input.replace(/```([\s\S]*?)```/g, (_match, inner) => {
44
+ const escaped = escapeHtml(inner.replace(/^\n/, "").replace(/\n$/, ""));
45
+ codeRegions.push(`<pre>${escaped}</pre>`);
46
+ return codePlaceholder(codeRegions.length - 1);
47
+ });
48
+ // Inline code `…`
49
+ body = body.replace(/`([^`\n]*)`/g, (_match, inner) => {
50
+ const escaped = escapeHtml(inner);
51
+ codeRegions.push(`<code>${escaped}</code>`);
52
+ return codePlaceholder(codeRegions.length - 1);
53
+ });
54
+ // ── 1b. Pull markdown links out before bold/italic transforms mangle URLs. ──
55
+ // The link text and URL will be HTML-escaped in step 2 below via the
56
+ // global escapeHtml pass on the placeholder-free body; the final <a> tag
57
+ // is assembled after escaping so the URL keeps its escaped form (single-escape,
58
+ // consistent with the existing `&`-in-URL behaviour).
59
+ const linkRegions = [];
60
+ body = body.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
61
+ linkRegions.push({ text, url });
62
+ return linkPlaceholder(linkRegions.length - 1);
63
+ });
64
+ // ── 2. Escape HTML-special chars in the remaining (non-code) text. ──
65
+ body = escapeHtml(body);
66
+ // ── 3. Bold first, into a sentinel; then italic won't eat its output. ──
67
+ const BOLD_OPEN = "\x01B\x01";
68
+ const BOLD_CLOSE = "\x02B\x02";
69
+ body = body.replace(/\*\*([^\n*]+?)\*\*/g, `${BOLD_OPEN}$1${BOLD_CLOSE}`);
70
+ body = body.replace(/__([^\n_]+?)__/g, `${BOLD_OPEN}$1${BOLD_CLOSE}`);
71
+ // Headings (#…) → bold
72
+ body = body.replace(/^\s{0,3}#{1,6}\s+(.*)$/gm, (_m, text) => `${BOLD_OPEN}${text.trim()}${BOLD_CLOSE}`);
73
+ // Strikethrough ~~text~~ → <s>text</s>
74
+ body = body.replace(/~~([^\n~]+?)~~/g, "<s>$1</s>");
75
+ // Italic *text* or _text_ → <i>text</i> (skip bold sentinels)
76
+ body = body.replace(/(^|[^*\w])\*(\S(?:[^*\n]*\S)?)\*(?!\w)/g, "$1<i>$2</i>");
77
+ body = body.replace(/(^|[^_\w])_(\S(?:[^_\n]*\S)?)_(?!\w)/g, "$1<i>$2</i>");
78
+ // Bullet list markers: `- ` / `* ` / `+ ` at the start of a line → "• "
79
+ body = body.replace(/^(\s*)[-*+]\s+/gm, "$1• ");
80
+ // ── 4. Restore sentinels and code regions. ──
81
+ body = body.replace(new RegExp(BOLD_OPEN, "g"), "<b>");
82
+ body = body.replace(new RegExp(BOLD_CLOSE, "g"), "</b>");
83
+ body = body.replace(CODE_PLACEHOLDER_RE, (_m, idx) => codeRegions[Number(idx)] ?? "");
84
+ // ── 5. Restore link placeholders LAST so URLs are never touched by markup passes. ──
85
+ // text and url were captured from the raw input before escapeHtml; escapeHtml
86
+ // was applied globally to the body (which at that point contained only the
87
+ // placeholder token), so here we escape text/url ourselves to produce the
88
+ // same single-escaped output the old inline regex produced.
89
+ body = body.replace(LINK_PLACEHOLDER_RE, (_m, idx) => {
90
+ const link = linkRegions[Number(idx)];
91
+ if (!link)
92
+ return "";
93
+ const escapedUrl = escapeHtml(link.url);
94
+ const escapedText = escapeHtml(link.text);
95
+ return `<a href="${escapedUrl}">${escapedText}</a>`;
96
+ });
97
+ return body;
98
+ }
@@ -0,0 +1,83 @@
1
+ import type { MessageRef } from "@copilotkit/channels-ui";
2
+ /** Sentinel scope used for DMs (DMs are flat — no thread). */
3
+ export declare const DM_SCOPE = "dm";
4
+ /**
5
+ * Stable key identifying one ongoing conversation with the bot.
6
+ *
7
+ * - For a group/forum thread: `{ chatId, scope: <threadId> }`
8
+ * - For a DM: `{ chatId, scope: "dm" }`
9
+ *
10
+ * The store uses the pair as a string key; conversations from different
11
+ * chats never collide.
12
+ */
13
+ export interface ConversationKey {
14
+ chatId: string;
15
+ scope: string;
16
+ }
17
+ /**
18
+ * Where to post a reply in Telegram. Used by the renderer; constructed by
19
+ * the listener once per turn.
20
+ */
21
+ export interface ReplyTarget {
22
+ chatId: number | string;
23
+ /** Forum thread id to post into. Omit for flat replies (DMs). */
24
+ messageThreadId?: number;
25
+ /** Message id to reply to. */
26
+ replyToMessageId?: number;
27
+ /** Whether the chat is a forum supergroup. */
28
+ isForum?: boolean;
29
+ /**
30
+ * The exact conversation key for this turn, stamped at ingress so egress
31
+ * (post/getMessages) keys history identically to the listener — avoids
32
+ * re-deriving (which can't reproduce the group `user:` scope).
33
+ */
34
+ conversationKey?: string;
35
+ }
36
+ /** A stable reference to one Telegram message. */
37
+ export interface TelegramMessageRef extends MessageRef {
38
+ id: string;
39
+ chatId: number | string;
40
+ messageId: number;
41
+ }
42
+ /** A single button in an inline keyboard. */
43
+ export interface TelegramInlineButton {
44
+ text: string;
45
+ callbackData?: string;
46
+ url?: string;
47
+ }
48
+ /** The wire payload sent to the Telegram Bot API. */
49
+ export interface TelegramPayload {
50
+ text: string;
51
+ parseMode: "HTML";
52
+ inlineKeyboard?: TelegramInlineButton[][];
53
+ photos?: {
54
+ url: string;
55
+ caption?: string;
56
+ }[];
57
+ }
58
+ /** Options accepted by the Telegram adapter constructor. */
59
+ export interface TelegramAdapterOptions {
60
+ /** Bot token from @BotFather. */
61
+ token: string;
62
+ /** How to receive updates. Defaults to "polling" (long-polling); "webhook" and "auto" are opt-in. */
63
+ mode?: "polling" | "webhook" | "auto";
64
+ /** Webhook configuration (required when mode is "webhook" or "auto" with a domain). */
65
+ webhook?: {
66
+ domain: string;
67
+ path?: string;
68
+ port?: number;
69
+ secretToken?: string;
70
+ };
71
+ /** AG-UI event names that should interrupt the running agent. */
72
+ interruptEventNames?: ReadonlySet<string>;
73
+ /** Surface "using <tool>…" status messages while the agent runs. */
74
+ showToolStatus?: boolean;
75
+ /** Posted when a user starts a conversation. */
76
+ greeting?: string;
77
+ /** Prompt chips shown at conversation start. */
78
+ suggestedPrompts?: {
79
+ title: string;
80
+ message: string;
81
+ }[];
82
+ }
83
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE1D,8DAA8D;AAC9D,eAAO,MAAM,QAAQ,OAAO,CAAC;AAE7B;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,iEAAiE;IACjE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8BAA8B;IAC9B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,kBAAmB,SAAQ,UAAU;IACpD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,6CAA6C;AAC7C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,qDAAqD;AACrD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,oBAAoB,EAAE,EAAE,CAAC;IAC1C,MAAM,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC9C;AAED,4DAA4D;AAC5D,MAAM,WAAW,sBAAsB;IACrC,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,qGAAqG;IACrG,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC;IACtC,uFAAuF;IACvF,OAAO,CAAC,EAAE;QACR,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,iEAAiE;IACjE,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC1C,oEAAoE;IACpE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gDAAgD;IAChD,gBAAgB,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACzD"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Sentinel scope used for DMs (DMs are flat — no thread). */
2
+ export const DM_SCOPE = "dm";