@capybara-hq/plugin-telegram 0.1.0-rc.6 → 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.6",
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.6",
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 = [];
@@ -1047,6 +1056,50 @@ async function writeOffset(kv, key, offset, log) {
1047
1056
  }
1048
1057
  }
1049
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
+
1050
1103
  // src/webhook.ts
1051
1104
  import { timingSafeEqual } from "node:crypto";
1052
1105
  var SECRET_HEADER = "x-telegram-bot-api-secret-token";
@@ -1127,6 +1180,13 @@ var tooLarge = () => new ChannelSendError2("message too long for a single Telegr
1127
1180
  reason: "too_large"
1128
1181
  });
1129
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;
1130
1190
  function createTelegramChannel(o) {
1131
1191
  const apiFor = (config, signal) => {
1132
1192
  const c = TelegramConnection.parse(config);
@@ -1167,6 +1227,33 @@ function createTelegramChannel(o) {
1167
1227
  return attempt(post.withoutUrlButtons);
1168
1228
  }
1169
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
+ }
1170
1257
  async function sendOne(api, target, post, extra) {
1171
1258
  let r;
1172
1259
  try {
@@ -1201,9 +1288,9 @@ function createTelegramChannel(o) {
1201
1288
  if (typeof me?.id !== "number" || !me.username) {
1202
1289
  throw new Error("Telegram getMe returned no bot user");
1203
1290
  }
1204
- if (me.can_read_all_group_messages) {
1291
+ if (!me.can_read_all_group_messages) {
1205
1292
  o.log.warn(
1206
- "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",
1207
1294
  { botId: me.id }
1208
1295
  );
1209
1296
  }
@@ -1285,8 +1372,11 @@ function createTelegramChannel(o) {
1285
1372
  },
1286
1373
  async send({ config, target, message, locale, signal }) {
1287
1374
  const { call, botId } = apiFor(config, signal);
1288
- const posts = await render(message, locale, botId);
1289
- 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
+ }
1290
1380
  const store = threadStore(o.kv, botId);
1291
1381
  const inThread = target.chatType === "group" && target.threadId !== void 0;
1292
1382
  const extra = {};
@@ -1298,25 +1388,50 @@ function createTelegramChannel(o) {
1298
1388
  const topic = await store.topicOf(target.threadId);
1299
1389
  if (topic !== void 0) extra.message_thread_id = topic;
1300
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
+ }
1301
1410
  let last = 0;
1302
1411
  for (const post of posts) {
1303
1412
  last = await sendOne({ call }, target, post, extra);
1304
- if (inThread && target.threadId !== void 0) {
1305
- try {
1306
- await store.recordSent(target.chatId, last, target.threadId);
1307
- } catch {
1308
- o.log.warn("telegram: could not record a sent message's thread", {
1309
- chatId: target.chatId,
1310
- messageId: last
1311
- });
1312
- }
1313
- }
1413
+ await record(last);
1314
1414
  }
1315
1415
  return { messageId: `${target.chatId}:${last}` };
1316
1416
  },
1317
1417
  async update({ config, messageId, message, locale, signal }) {
1318
1418
  const { call, botId } = apiFor(config, signal);
1319
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
+ }
1320
1435
  const posts = await render(message, locale, botId);
1321
1436
  if (posts.length > 1) throw tooLarge();
1322
1437
  const post = posts[0];
@@ -1334,9 +1449,7 @@ function createTelegramChannel(o) {
1334
1449
  })
1335
1450
  );
1336
1451
  } catch (e) {
1337
- if (e instanceof TelegramApiError && /message is not modified/i.test(e.description ?? "")) {
1338
- return;
1339
- }
1452
+ if (notModified(e)) return;
1340
1453
  throw toChannelSendError(e);
1341
1454
  }
1342
1455
  }
@@ -1346,7 +1459,7 @@ function createTelegramChannel(o) {
1346
1459
  // src/index.ts
1347
1460
  var index_default = defineExtension({
1348
1461
  name: "telegram",
1349
- version: "0.1.0-rc.6",
1462
+ version: "0.1.0-rc.7",
1350
1463
  sdk: "^0.1.0-rc.4",
1351
1464
  provides: [CHANNEL_EXTENSION_POINT],
1352
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.6",
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)",