@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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/adapter.d.ts +117 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +584 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +77 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +46 -0
- package/dist/chunked-edit-stream.d.ts +70 -0
- package/dist/chunked-edit-stream.d.ts.map +1 -0
- package/dist/chunked-edit-stream.js +197 -0
- package/dist/conversation-store.d.ts +56 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +98 -0
- package/dist/download-files.d.ts +68 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +157 -0
- package/dist/event-renderer.d.ts +39 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +295 -0
- package/dist/format-fallback.d.ts +6 -0
- package/dist/format-fallback.d.ts.map +1 -0
- package/dist/format-fallback.js +21 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +65 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +159 -0
- package/dist/listener.d.ts +16 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +419 -0
- package/dist/render/budget.d.ts +18 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +24 -0
- package/dist/render/telegram.d.ts +11 -0
- package/dist/render/telegram.d.ts.map +1 -0
- package/dist/render/telegram.js +429 -0
- package/dist/telegram-html.d.ts +22 -0
- package/dist/telegram-html.d.ts.map +1 -0
- package/dist/telegram-html.js +98 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- 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 `&`/`<` 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, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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