telegram-agent-kit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/telegram-agent-kit.svg)](./LICENSE)
7
7
  [![types](https://img.shields.io/npm/types/telegram-agent-kit.svg)](https://www.typescriptlang.org/)
8
8
 
9
- ESM-only. Runs on **Node 18+, Bun, Deno, and the browser** (the formatting core).
9
+ ESM-only. Runs on **Node 20+, Bun, Deno, and the browser** (the formatting core).
10
10
 
11
11
  ---
12
12
 
@@ -111,6 +111,12 @@ async function call(method: string, body: unknown, signal?: AbortSignal) {
111
111
  > transport. If your bot has no rich endpoint, point them at plain `sendMessage`
112
112
  > and set `rich: false` — the kit then renders via HTML only.
113
113
 
114
+ > **Note:** `rich: true` means *rich when it buys something*, not *rich always*.
115
+ > Drafts and final messages go out rich only for text that needs the rich
116
+ > renderer — today, a GFM table (`needsRich`); everything else is classic HTML, so
117
+ > ordinary replies keep the client's normal message font. `rich: false` is still
118
+ > the kill-switch: HTML only, always.
119
+
114
120
  ## How it works
115
121
 
116
122
  The kit is three layers plus one optional adapter. The dependency direction is
@@ -151,8 +157,9 @@ You implement these; the kit drives them.
151
157
  | `Checkpointer` | `{ snapshot(threadId), rollback(threadId, id) }` | Per-thread snapshot/rollback for clean recovery on a failed turn. |
152
158
  | `ThreadStore` | `{ resolve(chatKey, now), touch(chatKey, now) }` | Maps `{ chatId, agentId }` to a thread id (so two bots over one chat id don't collide). |
153
159
 
154
- A `RenderEvent` is one of `token`, `tool_start`, `tool_end`, or `error`. The kit
155
- appends `token` text to the live draft and treats an `error` event as a rollback.
160
+ A `RenderEvent` is either `token` or `error`: `token` text is appended to the live draft,
161
+ an `error` rolls the turn back, logs the message, and if you pass `errorNotice` tells
162
+ the user in the chat instead of going silent.
156
163
 
157
164
  ## API reference
158
165
 
@@ -165,6 +172,8 @@ appends `token` text to the live draft and treats an `error` event as a rollback
165
172
  - `chunkText(text)` / `safeSlice(text, max)` / `chunkRich(md)` — surrogate-safe splitting
166
173
  (classic limit 4096, rich limit 32768).
167
174
  - `repairRichTables(md)` · `neutralizeRichMedia(md)` · `extractTrailingCover(reply)` — rich helpers.
175
+ - `needsRich(md)` — does this text need the rich renderer (today: does it contain a GFM
176
+ table, outside code)? The gate behind `rich: true`; everything else goes classic.
168
177
 
169
178
  **Draft engine**
170
179
 
@@ -177,6 +186,8 @@ appends `token` text to the live draft and treats an `error` event as a rollback
177
186
  - `runTelegramTurn(opts)` — orchestrate one turn. Never throws out; every failure is caught and logged.
178
187
  Accepts an optional `configurable` bag forwarded to your `AgentStream` as `context.configurable`,
179
188
  for passing per-turn data (e.g. `pendingImages`) to the agent without widening the core input type.
189
+ Pass `errorNotice` (plain text, your language) to have a failed turn say so in the chat —
190
+ omit it and the user just sees the draft stop, which reads as being ignored.
180
191
  - `sendReply(client, chatId, reply, opts, signal?)` / `sendText(...)` — the send path on its own.
181
192
  - Types: `BotClient`, `AgentStream`, `Checkpointer`, `ThreadStore`, `RenderEvent`, `ChatKey`, `Logger`.
182
193
 
package/dist/index.d.ts CHANGED
@@ -5,7 +5,17 @@ type SendOpts = {
5
5
  rich: boolean;
6
6
  log: Logger;
7
7
  };
8
- /** Active reply path — rich when opts.rich, else classic. */
8
+ /** Active reply path — rich when `opts.rich`, but `sendRich` sends each chunk
9
+ * rich only if it actually needs the rich renderer (`needsRich`), else classic.
10
+ * `rich: true` means "rich when it buys something", not "rich always": the rich
11
+ * renderer sizes body text its own way with no Bot API field to override it, so
12
+ * pushing ordinary prose through it just makes every reply look unlike a normal
13
+ * message for nothing.
14
+ *
15
+ * Media is neutralized HERE, before the split, so it happens on BOTH paths:
16
+ * classic has no image renderer either (`mdToTelegramHtml` leaves `![a](u)`
17
+ * verbatim), so prose that routes to classic would otherwise show raw image
18
+ * markdown. Idempotent, so `sendReply`'s photo-fallback re-entry is harmless. */
9
19
  declare function sendText(client: BotClient, chatId: number, text: string, opts: SendOpts, signal?: AbortSignal): Promise<void>;
10
20
  /** Final reply send for a completed turn (client.ts sendReply). */
11
21
  declare function sendReply(client: BotClient, chatId: number, reply: string, opts: SendOpts, signal?: AbortSignal): Promise<void>;
@@ -78,6 +88,10 @@ type RunTelegramTurnOpts = {
78
88
  };
79
89
  draftConstants?: Partial<DraftConstants>;
80
90
  configurable?: Record<string, unknown>;
91
+ /** Plain-text line sent to the chat when the stream errors out (model chain
92
+ * exhausted, graph threw). Without it the turn returns silently and the user
93
+ * sees only a draft that stops moving — indistinguishable from being ignored. */
94
+ errorNotice?: string;
81
95
  };
82
96
  declare function runTelegramTurn(opts: RunTelegramTurnOpts): Promise<void>;
83
97
 
@@ -137,6 +151,27 @@ declare function mdToTelegramHtml(md: string, opts?: MdToHtmlOpts): string;
137
151
  * non-table prose (mid-body or trailing) is not eligible either (else the orphan
138
152
  * glues onto non-table content). Pure and total. */
139
153
  declare function repairRichTables(md: string): string;
154
+ /** Does `md` carry structure only the rich renderer can draw? Today that means
155
+ * one thing: a GFM table. Everything else a reply contains — bold, italic,
156
+ * code, links, quotes, lists — classic HTML renders fine, and classic keeps the
157
+ * client's normal message font, while the rich renderer sizes body text its own
158
+ * way with no Bot API knob to override it (the only size field in the whole
159
+ * rich schema is `RichBlockSectionHeading.size`). So routing prose to classic is
160
+ * the only way to keep ordinary replies looking like ordinary messages.
161
+ *
162
+ * Scanned per LINE, not per blank-line block: a table needs no blank line above
163
+ * it, and models routinely put one directly under a lead-in line, a heading, a
164
+ * list, or a closing fence. A block-first-line check misses every one of those
165
+ * (and every table after `\n\n\n` or in CRLF output, where the block split
166
+ * lands wrong) — and a missed table is the one case where classic is WORSE:
167
+ * it has no table renderer, so the reply goes out as literal pipes.
168
+ *
169
+ * Code regions are skipped, using the SAME `advanceCodeRegion` walk as
170
+ * `repairRichTables`: a table inside a fence or an HTML `<pre>`/`<code>` region
171
+ * is a literal example, and classic renders it as code perfectly well — it is
172
+ * not a reason to go rich. Only a header+delimiter pair counts; stray pipe rows
173
+ * print as literal pipes either way. Pure and total. */
174
+ declare function needsRich(md: string): boolean;
140
175
  /** Extract a standalone cover image that is the LAST non-empty line of `md`:
141
176
  * the whole line is a single `![alt](http(s)://…)` token. Returns the URL plus
142
177
  * the body (the reply with that line removed, trailing whitespace trimmed).
@@ -164,4 +199,4 @@ declare function extractTrailingCover(md: string): {
164
199
  * total. */
165
200
  declare function neutralizeRichMedia(md: string): string;
166
201
 
167
- export { AgentStream, BotClient, ChatKey, Checkpointer, DEFAULT_DRAFT_CONSTANTS, type DraftConstants, type DraftStreamer, type DraftStreamerDeps, Logger, type MdToHtmlOpts, type RunTelegramTurnOpts, TelegramApiError, ThreadStore, type TurnContext, chunkRich, chunkText, createDraftStreamer, extractTrailingCover, isBadRequest, mdToTelegramHtml, neutralizeRichMedia, repairRichTables, runTelegramTurn, safeSlice, sendReply, sendText };
202
+ export { AgentStream, BotClient, ChatKey, Checkpointer, DEFAULT_DRAFT_CONSTANTS, type DraftConstants, type DraftStreamer, type DraftStreamerDeps, Logger, type MdToHtmlOpts, type RunTelegramTurnOpts, TelegramApiError, ThreadStore, type TurnContext, chunkRich, chunkText, createDraftStreamer, extractTrailingCover, isBadRequest, mdToTelegramHtml, needsRich, neutralizeRichMedia, repairRichTables, runTelegramTurn, safeSlice, sendReply, sendText };
package/dist/index.js CHANGED
@@ -365,6 +365,16 @@ ${block}`;
365
365
  }
366
366
  return out.join("\n\n");
367
367
  }
368
+ function needsRich(md) {
369
+ if (!md.includes("|")) return false;
370
+ const lines = md.split("\n");
371
+ let region = null;
372
+ for (let i = 0; i + 1 < lines.length; i++) {
373
+ if (region === null && isTableBlock(lines.slice(i, i + 2))) return true;
374
+ region = advanceCodeRegion(region, [lines[i] ?? ""]);
375
+ }
376
+ return false;
377
+ }
368
378
  var TRAILING_COVER_RE = /^!\[[^\]]*\]\(\s*(https?:\/\/[^\s)]+)[^)]*\)$/;
369
379
  function insideCodeRegion(lines, idx) {
370
380
  return advanceCodeRegion(null, lines.slice(0, idx)) !== null;
@@ -461,9 +471,11 @@ async function sendClassic(client, chatId, text, signal) {
461
471
  }
462
472
  }
463
473
  async function sendRich(client, chatId, markdown, log, signal) {
464
- for (const piece of chunkRich(
465
- neutralizeRichMedia(repairRichTables(markdown))
466
- )) {
474
+ for (const piece of chunkRich(repairRichTables(markdown))) {
475
+ if (!needsRich(piece)) {
476
+ await sendClassic(client, chatId, piece, signal);
477
+ continue;
478
+ }
467
479
  try {
468
480
  await client.sendRichMessage({ chatId, markdown: piece }, signal);
469
481
  } catch (err) {
@@ -479,8 +491,9 @@ async function sendRich(client, chatId, markdown, log, signal) {
479
491
  }
480
492
  }
481
493
  async function sendText(client, chatId, text, opts, signal) {
482
- if (opts.rich) await sendRich(client, chatId, text, opts.log, signal);
483
- else await sendClassic(client, chatId, text, signal);
494
+ const md = neutralizeRichMedia(text);
495
+ if (opts.rich) await sendRich(client, chatId, md, opts.log, signal);
496
+ else await sendClassic(client, chatId, md, signal);
484
497
  }
485
498
  async function sendCover(client, chatId, url, caption, signal) {
486
499
  if (caption === void 0 || caption === "") {
@@ -519,7 +532,7 @@ async function sendReply(client, chatId, reply, opts, signal) {
519
532
  else await sendCover(client, chatId, cover.url, cover.body, signal);
520
533
  } catch (err) {
521
534
  if (!isBadRequest(err)) throw err;
522
- await sendText(client, chatId, neutralizeRichMedia(reply), opts, signal);
535
+ await sendText(client, chatId, reply, opts, signal);
523
536
  return;
524
537
  }
525
538
  if (longBody) await sendText(client, chatId, cover.body, opts, signal);
@@ -574,7 +587,7 @@ function createDraftStreamer(deps) {
574
587
  const controller = new AbortController();
575
588
  inFlightController = controller;
576
589
  lastAttemptAt = t;
577
- const usingRich = richMode;
590
+ const usingRich = richMode && needsRich(text);
578
591
  const op = usingRich ? client.sendRichMessageDraft(
579
592
  { chatId, draftId, markdown: text },
580
593
  controller.signal
@@ -724,7 +737,7 @@ async function runTelegramTurn(opts) {
724
737
  });
725
738
  draft.start();
726
739
  let reply = "";
727
- let errored = false;
740
+ let errorMessage;
728
741
  for await (const ev of opts.agentStream(
729
742
  { messages: [{ role: "user", content: opts.userText }] },
730
743
  { threadId, signal: opts.signal, configurable: opts.configurable }
@@ -732,15 +745,27 @@ async function runTelegramTurn(opts) {
732
745
  if (ev.type === "token") {
733
746
  reply += ev.text;
734
747
  draft.push(reply);
735
- } else if (ev.type === "error") errored = true;
748
+ } else if (ev.type === "error") errorMessage = ev.message;
736
749
  }
737
- if (errored) {
750
+ if (errorMessage !== void 0) {
751
+ log.error("telegram turn errored", {
752
+ chatId: opts.chatKey.chatId,
753
+ err: errorMessage
754
+ });
738
755
  draftTornDown = true;
739
756
  await draft.abort().catch(() => {
740
757
  });
741
758
  await opts.checkpointer.rollback(rollback.threadId, rollback.checkpointId).catch(
742
759
  (e) => log.error("telegram rollback failed", { err: String(e) })
743
760
  );
761
+ if (opts.errorNotice && !opts.signal?.aborted) {
762
+ await opts.client.sendMessage(
763
+ { chatId: opts.chatKey.chatId, text: opts.errorNotice },
764
+ opts.signal
765
+ ).catch(
766
+ (e) => log.error("telegram error notice failed", { err: String(e) })
767
+ );
768
+ }
744
769
  return;
745
770
  }
746
771
  draftTornDown = true;
@@ -790,6 +815,7 @@ export {
790
815
  extractTrailingCover,
791
816
  isBadRequest,
792
817
  mdToTelegramHtml,
818
+ needsRich,
793
819
  neutralizeRichMedia,
794
820
  repairRichTables,
795
821
  runTelegramTurn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telegram-agent-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Render markdown to Telegram, stream agent replies into live drafts, and drive a snapshot/rollback turn-loop — runtime-agnostic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "esm"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=18"
31
+ "node": ">=20"
32
32
  },
33
33
  "files": [
34
34
  "dist"
@@ -65,10 +65,10 @@
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.0",
67
67
  "@langchain/core": "^1.2.0",
68
- "@types/node": "^22.0.0",
68
+ "@types/node": "^24.13.3",
69
69
  "deepagents": "^1.10.5",
70
70
  "tsup": "^8.3.0",
71
- "typescript": "^5.6.0",
72
- "vitest": "^2.1.0"
71
+ "typescript": "^6.0.3",
72
+ "vitest": "^4.1.10"
73
73
  }
74
74
  }