@capybara-hq/plugin-telegram 0.1.0-rc.5 → 0.1.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "telegram",
4
- "version": "0.1.0-rc.5",
4
+ "version": "0.1.0-rc.7",
5
5
  "publisher": "Capybara",
6
6
  "description": {
7
7
  "en": "Telegram (channel@1 bot over the Bot API: long polling or webhook, inline keyboard cards)",
@@ -17,7 +17,7 @@
17
17
  "entry": "./dist/index.mjs",
18
18
  "manifest": {
19
19
  "name": "telegram",
20
- "version": "0.1.0-rc.5",
20
+ "version": "0.1.0-rc.7",
21
21
  "sdk": "^0.1.0-rc.4",
22
22
  "provides": [
23
23
  "channel@1"
package/README.md CHANGED
@@ -14,18 +14,21 @@ global (one person has the same id for every bot), so one person is one identity
14
14
 
15
15
  The Telegram docs cited here are the [Bot API reference](https://core.telegram.org/bots/api) (Bot API 10.3), consulted on 2026-11-08.
16
16
 
17
- Verified live on 2026-11-08 against a real bot: verify, send, edit, a 5000-character split send and a card with an inline callback keyboard, in a private chat and in a basic group. Not covered live: a person clicking a card button, webhook mode and forum supergroups.
17
+ Verified live on 2026-11-08 against a real bot: verify, send, edit, a 5000-character split send and a card with an inline callback keyboard, in a private chat and in a basic group. Verified live on 2026-11-10: a reply with a table sent as a rich message, and a plain draft edited into one and edited again, in a private chat (live test `rich table`; in Node, a slow IPv4 connect can lose to an unreachable IPv6 address: run it with `NODE_OPTIONS=--network-family-autoselection-attempt-timeout=3000` if every call fails with a network error). Not covered live: a person clicking a card button, webhook mode and forum supergroups.
18
18
 
19
19
  ## Create the bot
20
20
 
21
21
  1. In Telegram, open [@BotFather](https://t.me/BotFather) and send `/newbot`. Pick a display name and a username
22
22
  (it must end in `bot`). BotFather replies with the bot token, such as `123456:ABC-DEF…`
23
23
  ([Bot API: authorizing your bot](https://core.telegram.org/bots/api#authorizing-your-bot)). Treat it like a password.
24
- 2. **Privacy mode** (`/setprivacy`): leave it **enabled** (the default). With it on, a bot in a group receives only
25
- commands, mentions of the bot and replies to the bot's own messages, which is exactly what this plugin forwards
26
- ([Bot API: `getMe`, `can_read_all_group_messages`](https://core.telegram.org/bots/api#getme)).
27
- If you turn it off the bot receives every group message; the plugin still forwards only mentions and replies to
28
- the bot, and `verify` logs a warning. Telegram applies a privacy change to a group only after the bot is removed from the group and added again.
24
+ 2. **Privacy mode** (`/setprivacy`): for groups, turn it **off** (`Disable`), or make the bot an admin of
25
+ each group it joins. With privacy mode on (the default), Telegram delivers group commands addressed to the
26
+ bot (`/cmd@botname`) and replies to the bot's messages, but **not** a plain `@botname` mention, although
27
+ BotFather's description says otherwise (verified live 2026-11-10;
28
+ [Bot API: privacy mode](https://core.telegram.org/bots/features#privacy-mode)). With it off the bot receives
29
+ every group message; the plugin still forwards only mentions of the bot and replies to it, everything else
30
+ is dropped. Telegram applies a privacy change to a group only after the bot is removed from the group and
31
+ added again. `verify` logs a warning while privacy mode is on. Private chats work either way.
29
32
  3. **Groups** (`/setjoingroups`): enable it if people should add the bot to groups, disable it for a private-only bot.
30
33
  4. **Topics in private chats**: leave the bot's topic mode off. It is not supported (see [Behavior](#behavior)); `verify` warns when it is on.
31
34
  5. In the Capybara org settings, enable the `telegram` plugin, then **create a new channel connection** under Channels:
@@ -85,13 +88,22 @@ The deployment-level config lives under the kernel config `extensionConfig.teleg
85
88
  - **Format**: the model reply's Markdown is converted to Telegram HTML (`parse_mode: "HTML"`); everything else is escaped.
86
89
  Link previews are off. Links are limited to http(s): `tg://` and other schemes stay plain text, so model text cannot produce `tg://user?id=…` mentions.
87
90
  A raw `@username` in model text would notify people, so the converter puts a U+2060 WORD JOINER after the `@` outside code and links, and Telegram no longer parses it as a mention.
88
- - **Limit and splitting**: one message holds at most 4096 characters after entity parsing
91
+ - **Tables**: a reply with a Markdown (GFM) table is sent as a [rich message](https://core.telegram.org/bots/api/rich-messages)
92
+ (Bot API 10.1: [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage), and `editMessageText` with `rich_message` while it streams),
93
+ so the table renders natively (bordered, striped, column alignment from the delimiter row). The rest of the reply goes through the same converter
94
+ as plain replies (escaping, http(s)-only links, the mention guard), as rich HTML with `<br>` line breaks (rich HTML collapses bare newlines).
95
+ A rich message holds up to 32768 characters, so such a reply is never split. A table wider than 20 columns becomes the list described below;
96
+ a reply over 500 blocks (table rows count) or 32768 characters, or one with cards, is sent as plain HTML.
97
+ When Telegram refuses the rich message for its content (any 400, or 404 from a Bot API server without the method), the same reply is sent or edited
98
+ as plain HTML, where each table is a list (one item per row, led by its first cell); flood waits, 5xx and a blocked bot are not retried as plain HTML.
99
+ Telegram turns a text message into a rich one and back on edit, so streaming keeps the send-then-edit flow (no delete and resend, no extra notification).
100
+ - **Limit and splitting**: one plain message holds at most 4096 characters after entity parsing
89
101
  ([`sendMessage`, `text`](https://core.telegram.org/bots/api#sendmessage)); longer text is split into several messages and the last id is returned.
90
102
  An edit that would need several messages reports `too_large` and the kernel sends a new message instead.
91
103
  - **Streaming edits**: replies are sent and then edited ([`editMessageText`](https://core.telegram.org/bots/api#editmessagetext)); `minEditIntervalMs = 3000`.
92
104
  Telegram's limit for a group is about 20 messages per minute ([Broadcasting to users, FAQ](https://core.telegram.org/bots/faq))
93
105
  and edits count; a 429 is honored through `retry_after`. "message is not modified" on an edit counts as success.
94
- [`sendMessageDraft`](https://core.telegram.org/bots/api#sendmessagedraft) is not used: it exists only in private chats, is a 30-second preview that must be followed by a real
106
+ [`sendMessageDraft`](https://core.telegram.org/bots/api#sendmessagedraft) and `sendRichMessageDraft` are not used: it exists only in private chats, is a 30-second preview that must be followed by a real
95
107
  `sendMessage`, and does not fit the kernel's send-then-edit model.
96
108
  - **Idempotency**: the Bot API has no idempotent send, so `idempotencyKey` is ignored. A send whose response is lost and is retried can show the message twice; the alternative (never retrying) would lose messages.
97
109
  - **Permission manifest**: `network: ["api.telegram.org"]`, shown only and not enforced.
package/dist/index.mjs CHANGED
@@ -291,9 +291,14 @@ function splitRow(line) {
291
291
  }
292
292
  var candidate = (line) => !/^\s{4,}/.test(line) && !/^\s*>/.test(line);
293
293
  function delimiterRow(line, width) {
294
- if (line === void 0 || !candidate(line)) return false;
294
+ if (line === void 0 || !candidate(line)) return void 0;
295
295
  const cells = splitRow(line);
296
- return cells !== void 0 && cells.length === width && cells.every((c) => DELIM_CELL.test(c));
296
+ if (cells === void 0 || cells.length !== width || !cells.every((c) => DELIM_CELL.test(c))) {
297
+ return void 0;
298
+ }
299
+ return cells.map(
300
+ (c) => c.startsWith(":") ? c.endsWith(":") ? "center" : "left" : c.endsWith(":") ? "right" : void 0
301
+ );
297
302
  }
298
303
  var fit = (cells, width) => Array.from({ length: width }, (_, i) => cells[i] ?? "");
299
304
  function segmentTables(md) {
@@ -313,7 +318,8 @@ function segmentTables(md) {
313
318
  continue;
314
319
  }
315
320
  const header = !inFence && candidate(line) ? splitRow(line) : void 0;
316
- if (!header || !delimiterRow(lines[i + 1], header.length)) {
321
+ const align = header && delimiterRow(lines[i + 1], header.length);
322
+ if (!header || !align) {
317
323
  text.push(line);
318
324
  continue;
319
325
  }
@@ -327,7 +333,7 @@ function segmentTables(md) {
327
333
  rows.push(fit(cells, header.length));
328
334
  }
329
335
  flushText();
330
- out.push({ kind: "table", table: { header, rows } });
336
+ out.push({ kind: "table", table: { header, rows, align } });
331
337
  i = j - 1;
332
338
  }
333
339
  flushText();
@@ -337,7 +343,7 @@ var BR = /<br\s*\/?>/gi;
337
343
  var CJK_END = /[⺀-鿿가-힯豈-﫿＀-￯]$/u;
338
344
  var INDENT = " ";
339
345
  var pair = (h, v) => h === "" ? v : `${h}${CJK_END.test(h) ? ":" : ": "}${v}`;
340
- var lead = (cell) => /`|\*\*|__/.test(cell) ? cell : `**${cell}**`;
346
+ var lead = (cell2) => /`|\*\*|__/.test(cell2) ? cell2 : `**${cell2}**`;
341
347
  function tableToListMarkdown(t) {
342
348
  const clean = (s) => s.replace(BR, " / ").trim();
343
349
  const header = t.header.map(clean);
@@ -442,8 +448,8 @@ function codeBlock(lang, lines) {
442
448
  const l = lang.replace(/[^A-Za-z0-9_+#.-]/g, "");
443
449
  return l === "" ? `<pre>${body}</pre>` : `<pre><code class="language-${l}">${body}</code></pre>`;
444
450
  }
445
- function markdownToTelegramHtml(md) {
446
- const lines = tablesToLists(md.replace(/\r\n?/g, "\n").replaceAll("\0", "")).split("\n");
451
+ var normaliseMarkdown = (md) => md.replace(/\r\n?/g, "\n").replaceAll("\0", "");
452
+ function convertLines(lines) {
447
453
  const out = [];
448
454
  let fence;
449
455
  let quote;
@@ -476,7 +482,10 @@ function markdownToTelegramHtml(md) {
476
482
  }
477
483
  if (fence) out.push(codeBlock(fence.lang, fence.lines));
478
484
  closeQuote();
479
- return neutraliseHtmlMentions(out.join("\n"));
485
+ return out.join("\n");
486
+ }
487
+ function markdownToTelegramHtml(md) {
488
+ return neutraliseHtmlMentions(convertLines(tablesToLists(normaliseMarkdown(md)).split("\n")));
480
489
  }
481
490
  function tokenise(html) {
482
491
  const tokens = [];
@@ -599,10 +608,12 @@ var urlButton = (text, url) => isHttp(url) ? [{ text, url }] : [];
599
608
  function cardText(card, locale) {
600
609
  const url = card.kind === "connect" || card.kind === "bind" ? card.url : card.webUrl;
601
610
  const lines = renderCardAsText(card, locale).split("\n");
602
- const last = lines[lines.length - 1];
603
- if (lines.length > 1 && last?.endsWith(url)) {
604
- lines.pop();
605
- const label = markdownToTelegramHtml(last.slice(0, last.length - url.length));
611
+ let at = lines.length - 1;
612
+ if (card.kind === "question" && card.state === "pending" && card.textReply !== void 0) at--;
613
+ const line = lines[at];
614
+ if (at > 0 && line?.endsWith(url)) {
615
+ lines.splice(at, 1);
616
+ const label = markdownToTelegramHtml(line.slice(0, line.length - url.length));
606
617
  return { body: markdownToTelegramHtml(lines.join("\n")), linkLine: `${label}${htmlLink(url)}` };
607
618
  }
608
619
  return { body: markdownToTelegramHtml(lines.join("\n")) };
@@ -649,7 +660,9 @@ async function keyboard(card, locale, tokens) {
649
660
  case "question": {
650
661
  const f = card.form.fields[0];
651
662
  const rows = [];
652
- if (card.state === "pending" && f && !f.multiSelect && questionRenderableInline(card.form) && "enum" in f.schema) {
663
+ if (card.state === "pending" && f && !f.multiSelect && // With textReply the Other path is a plain reply: a field with allowOther still gets its
664
+ // option buttons, never an Other button.
665
+ questionRenderableInline(card.form, { textReply: card.textReply !== void 0 }) && "enum" in f.schema) {
653
666
  for (const option of f.schema.enum) {
654
667
  rows.push(
655
668
  await callbackButton(tokens, option, {
@@ -1043,6 +1056,50 @@ async function writeOffset(kv, key, offset, log) {
1043
1056
  }
1044
1057
  }
1045
1058
 
1059
+ // src/rich.ts
1060
+ var RICH_LIMITS = { text: 32768, blocks: 500, columns: 20 };
1061
+ function cell(tag, text, align) {
1062
+ const body = text.split(BR).map((part) => inline(part.trim())).join("<br>");
1063
+ return `<${tag}${align ? ` align="${align}"` : ""}>${body}</${tag}>`;
1064
+ }
1065
+ function tableToRichHtml(t) {
1066
+ const row = (tag, cells) => `<tr>${cells.map((c, i) => cell(tag, c, t.align[i])).join("")}</tr>`;
1067
+ return `<table bordered striped>${row("th", t.header)}${t.rows.map((r) => row("td", r)).join("")}</table>`;
1068
+ }
1069
+ function textToRichHtml(html) {
1070
+ return html.split(/(<pre[^>]*>[\s\S]*?<\/pre>)/).map(
1071
+ (part, i) => i % 2 === 1 ? part : part.replace(/\n*(<\/?blockquote>)\n*/g, "$1").replace(/^\n+|\n+$/g, "").replace(/\n/g, "<br>")
1072
+ ).join("");
1073
+ }
1074
+ function markdownToRichHtml(md) {
1075
+ const segments = segmentTables(normaliseMarkdown(md));
1076
+ const native = (t) => t.header.length <= RICH_LIMITS.columns;
1077
+ if (!segments.some((s) => s.kind === "table" && native(s.table))) return void 0;
1078
+ const parts = [];
1079
+ let lines = [];
1080
+ let blocks = 0;
1081
+ const flush = () => {
1082
+ const html2 = textToRichHtml(convertLines(lines));
1083
+ lines = [];
1084
+ if (html2 === "") return;
1085
+ parts.push(html2);
1086
+ blocks += 1 + (html2.match(/<pre|<blockquote/g)?.length ?? 0);
1087
+ };
1088
+ for (const s of segments) {
1089
+ if (s.kind === "text") lines.push(...s.lines);
1090
+ else if (!native(s.table)) lines.push(...tableToListMarkdown(s.table));
1091
+ else {
1092
+ flush();
1093
+ parts.push(tableToRichHtml(s.table));
1094
+ blocks += 1 + s.table.rows.length + 1;
1095
+ }
1096
+ }
1097
+ flush();
1098
+ const html = neutraliseHtmlMentions(parts.join(""));
1099
+ if (blocks > RICH_LIMITS.blocks || visibleLength(html) > RICH_LIMITS.text) return void 0;
1100
+ return html;
1101
+ }
1102
+
1046
1103
  // src/webhook.ts
1047
1104
  import { timingSafeEqual } from "node:crypto";
1048
1105
  var SECRET_HEADER = "x-telegram-bot-api-secret-token";
@@ -1123,6 +1180,13 @@ var tooLarge = () => new ChannelSendError2("message too long for a single Telegr
1123
1180
  reason: "too_large"
1124
1181
  });
1125
1182
  var urlButtonRejected = (e) => e instanceof TelegramApiError && (e.errorCode ?? e.status) === 400 && /button URL|Wrong HTTP URL|BUTTON_URL_INVALID/i.test(e.description ?? "");
1183
+ var richRefused = (e) => {
1184
+ if (!(e instanceof TelegramApiError)) return false;
1185
+ const mapped = toChannelSendError(e);
1186
+ return !mapped.retryable && (mapped.reason === "rejected" || mapped.reason === "too_large");
1187
+ };
1188
+ var notModified = (e) => e instanceof TelegramApiError && /message is not modified/i.test(e.description ?? "");
1189
+ var richOf = (message) => (message.cards ?? []).length === 0 && message.text.trim() !== "" ? markdownToRichHtml(message.text) : void 0;
1126
1190
  function createTelegramChannel(o) {
1127
1191
  const apiFor = (config, signal) => {
1128
1192
  const c = TelegramConnection.parse(config);
@@ -1163,6 +1227,33 @@ function createTelegramChannel(o) {
1163
1227
  return attempt(post.withoutUrlButtons);
1164
1228
  }
1165
1229
  }
1230
+ const warnRichFallback = (e, method) => o.log.warn("telegram: Telegram refused a rich message; the reply is sent as plain HTML", {
1231
+ method,
1232
+ ...e instanceof TelegramApiError && { error: e.description ?? e.message }
1233
+ });
1234
+ async function sendRich(api, target, html, extra) {
1235
+ let r;
1236
+ try {
1237
+ r = await api.call("sendRichMessage", {
1238
+ chat_id: target.chatId,
1239
+ rich_message: { html },
1240
+ ...extra
1241
+ });
1242
+ } catch (e) {
1243
+ if (richRefused(e)) {
1244
+ warnRichFallback(e, "sendRichMessage");
1245
+ return void 0;
1246
+ }
1247
+ throw toChannelSendError(e);
1248
+ }
1249
+ if (typeof r?.message_id !== "number") {
1250
+ throw new ChannelSendError2("Telegram returned no message id", {
1251
+ retryable: false,
1252
+ reason: "rejected"
1253
+ });
1254
+ }
1255
+ return r.message_id;
1256
+ }
1166
1257
  async function sendOne(api, target, post, extra) {
1167
1258
  let r;
1168
1259
  try {
@@ -1197,9 +1288,9 @@ function createTelegramChannel(o) {
1197
1288
  if (typeof me?.id !== "number" || !me.username) {
1198
1289
  throw new Error("Telegram getMe returned no bot user");
1199
1290
  }
1200
- if (me.can_read_all_group_messages) {
1291
+ if (!me.can_read_all_group_messages) {
1201
1292
  o.log.warn(
1202
- "telegram: the bot can read all group messages (privacy mode is off); only mentions and replies to the bot are forwarded",
1293
+ "telegram: privacy mode is on, so in groups the bot gets only commands and replies to its messages, not plain @mentions; turn privacy mode off in BotFather (then remove and re-add the bot to each group) or make the bot a group admin",
1203
1294
  { botId: me.id }
1204
1295
  );
1205
1296
  }
@@ -1281,8 +1372,11 @@ function createTelegramChannel(o) {
1281
1372
  },
1282
1373
  async send({ config, target, message, locale, signal }) {
1283
1374
  const { call, botId } = apiFor(config, signal);
1284
- const posts = await render(message, locale, botId);
1285
- if (posts.length === 0) throw new ChannelSendError2("nothing to send", { retryable: false });
1375
+ const rich = richOf(message);
1376
+ const posts = rich === void 0 ? await render(message, locale, botId) : [];
1377
+ if (rich === void 0 && posts.length === 0) {
1378
+ throw new ChannelSendError2("nothing to send", { retryable: false });
1379
+ }
1286
1380
  const store = threadStore(o.kv, botId);
1287
1381
  const inThread = target.chatType === "group" && target.threadId !== void 0;
1288
1382
  const extra = {};
@@ -1294,25 +1388,50 @@ function createTelegramChannel(o) {
1294
1388
  const topic = await store.topicOf(target.threadId);
1295
1389
  if (topic !== void 0) extra.message_thread_id = topic;
1296
1390
  }
1391
+ const record = async (sent) => {
1392
+ if (!inThread || target.threadId === void 0) return;
1393
+ try {
1394
+ await store.recordSent(target.chatId, sent, target.threadId);
1395
+ } catch {
1396
+ o.log.warn("telegram: could not record a sent message's thread", {
1397
+ chatId: target.chatId,
1398
+ messageId: sent
1399
+ });
1400
+ }
1401
+ };
1402
+ if (rich !== void 0) {
1403
+ const sent = await sendRich({ call }, target, rich, extra);
1404
+ if (sent !== void 0) {
1405
+ await record(sent);
1406
+ return { messageId: `${target.chatId}:${sent}` };
1407
+ }
1408
+ posts.push(...await render(message, locale, botId));
1409
+ }
1297
1410
  let last = 0;
1298
1411
  for (const post of posts) {
1299
1412
  last = await sendOne({ call }, target, post, extra);
1300
- if (inThread && target.threadId !== void 0) {
1301
- try {
1302
- await store.recordSent(target.chatId, last, target.threadId);
1303
- } catch {
1304
- o.log.warn("telegram: could not record a sent message's thread", {
1305
- chatId: target.chatId,
1306
- messageId: last
1307
- });
1308
- }
1309
- }
1413
+ await record(last);
1310
1414
  }
1311
1415
  return { messageId: `${target.chatId}:${last}` };
1312
1416
  },
1313
1417
  async update({ config, messageId, message, locale, signal }) {
1314
1418
  const { call, botId } = apiFor(config, signal);
1315
1419
  const { chatId, messageId: id } = parseMessageId(messageId);
1420
+ const rich = richOf(message);
1421
+ if (rich !== void 0) {
1422
+ try {
1423
+ await call("editMessageText", {
1424
+ chat_id: chatId,
1425
+ message_id: id,
1426
+ rich_message: { html: rich }
1427
+ });
1428
+ return;
1429
+ } catch (e) {
1430
+ if (notModified(e)) return;
1431
+ if (!richRefused(e)) throw toChannelSendError(e);
1432
+ warnRichFallback(e, "editMessageText");
1433
+ }
1434
+ }
1316
1435
  const posts = await render(message, locale, botId);
1317
1436
  if (posts.length > 1) throw tooLarge();
1318
1437
  const post = posts[0];
@@ -1330,9 +1449,7 @@ function createTelegramChannel(o) {
1330
1449
  })
1331
1450
  );
1332
1451
  } catch (e) {
1333
- if (e instanceof TelegramApiError && /message is not modified/i.test(e.description ?? "")) {
1334
- return;
1335
- }
1452
+ if (notModified(e)) return;
1336
1453
  throw toChannelSendError(e);
1337
1454
  }
1338
1455
  }
@@ -1342,7 +1459,7 @@ function createTelegramChannel(o) {
1342
1459
  // src/index.ts
1343
1460
  var index_default = defineExtension({
1344
1461
  name: "telegram",
1345
- version: "0.1.0-rc.5",
1462
+ version: "0.1.0-rc.7",
1346
1463
  sdk: "^0.1.0-rc.4",
1347
1464
  provides: [CHANNEL_EXTENSION_POINT],
1348
1465
  permissions: { network: ["api.telegram.org"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-telegram",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.7",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Telegram (channel@1 bot over the Bot API: long polling or webhook, inline keyboard cards)",