talon-agent 3.8.1 → 3.9.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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -2
  3. package/src/backend/claude-sdk/handler.ts +1 -1
  4. package/src/backend/codex/handler/message.ts +1 -1
  5. package/src/backend/kilo/handler/index.ts +1 -2
  6. package/src/backend/kilo/handler/state.ts +3 -8
  7. package/src/backend/kilo/index.ts +2 -28
  8. package/src/backend/openai-agents/handler/message.ts +1 -1
  9. package/src/backend/openai-agents/session.ts +1 -1
  10. package/src/backend/opencode/handler/index.ts +1 -2
  11. package/src/backend/opencode/handler/state.ts +3 -8
  12. package/src/backend/opencode/index.ts +0 -7
  13. package/src/backend/shared/delivery.ts +1 -1
  14. package/src/backend/shared/handle-retry.ts +1 -1
  15. package/src/backend/shared/index.ts +3 -2
  16. package/src/backend/shared/turn-interrupt.ts +1 -1
  17. package/src/cli/index.ts +0 -8
  18. package/src/core/bus/index.ts +1 -9
  19. package/src/core/mesh/registry.ts +106 -4
  20. package/src/core/mesh/service.ts +22 -7
  21. package/src/core/mesh/transfers.ts +33 -4
  22. package/src/core/plugin/index.ts +0 -9
  23. package/src/core/prompt/index.ts +1 -16
  24. package/src/core/vfs/index.ts +2 -21
  25. package/src/core/weaver/index.ts +5 -9
  26. package/src/frontend/discord/actions/messaging.ts +73 -13
  27. package/src/frontend/discord/actions/shared.ts +21 -3
  28. package/src/frontend/discord/commands/admin.ts +1 -1
  29. package/src/frontend/discord/errors.ts +16 -5
  30. package/src/frontend/discord/formatting.ts +19 -4
  31. package/src/frontend/native/index.ts +22 -8
  32. package/src/frontend/native/protocol.ts +6 -0
  33. package/src/frontend/native/server.ts +171 -12
  34. package/src/frontend/shared/format.ts +6 -0
  35. package/src/frontend/telegram/actions/messaging.ts +120 -24
  36. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  37. package/src/frontend/telegram/commands/admin.ts +1 -1
  38. package/src/frontend/telegram/formatting.ts +64 -19
  39. package/src/frontend/terminal/commands.ts +46 -8
  40. package/src/frontend/terminal/input.ts +110 -12
  41. package/src/frontend/terminal/renderer.ts +2 -0
  42. package/src/{util → storage}/metrics.ts +1 -1
  43. package/src/storage/scheduled-store.ts +6 -3
  44. package/src/util/config.ts +8 -0
  45. package/src/util/log.ts +1 -0
  46. package/src/backend/codex/index.ts +0 -33
  47. package/src/backend/openai-agents/index.ts +0 -48
  48. package/src/cli/plugin-entries.ts +0 -104
  49. package/src/core/agent-runtime/index.ts +0 -62
  50. package/src/core/background/index.ts +0 -21
  51. package/src/core/engine/index.ts +0 -20
  52. package/src/core/models/index.ts +0 -17
  53. package/src/core/soul/index.ts +0 -140
  54. package/src/core/tools/mcp-server.ts +0 -111
  55. /package/src/{backend/shared → util}/session-name.ts +0 -0
@@ -133,14 +133,17 @@ export type BridgeServerHandlers = {
133
133
  | Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
134
134
  /** A device answered a device_command; true when a call was waiting. */
135
135
  completeCommand(body: Record<string, unknown>): boolean;
136
- /** A device streams a pull-transfer's file body up (raw request body). */
136
+ /** A device streams a pull-transfer's file body up (raw request body).
137
+ * `fromDeviceId` is the caller's claimed identity, when it sent one. */
137
138
  acceptFileUpload(
138
139
  token: string,
139
140
  body: IncomingMessage,
141
+ fromDeviceId?: string,
140
142
  ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
141
143
  /** Resolve a push-transfer token to the file to stream down, or null. */
142
144
  openFileDownload(
143
145
  token: string,
146
+ fromDeviceId?: string,
144
147
  ): Promise<{ path: string; size: number } | null>;
145
148
  /** Resolve a node-provisioning token to its installer script, or null. */
146
149
  openNodeInstall(token: string): { script: string; filename: string } | null;
@@ -152,6 +155,12 @@ const SSE_PING_MS = 25_000;
152
155
  const MAX_BODY_BYTES = 256 * 1024;
153
156
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
154
157
  const PORT_FALLBACKS = 5;
158
+ /**
159
+ * Longest device id a client may claim (`?deviceId=…`). Matches the
160
+ * registry's own id cap — a longer id can never name a real device, and the
161
+ * claim is held for the life of a connection, so it stays a bounded key.
162
+ */
163
+ const MAX_DEVICE_ID_CHARS = 128;
155
164
 
156
165
  // Failed-auth lockout: after this many wrong tokens from one address inside
157
166
  // the window, that address gets 429s until the window lapses. The token's
@@ -173,7 +182,13 @@ type AuthState = "ok" | "anonymous" | "bad";
173
182
 
174
183
  export class BridgeServer {
175
184
  private server: Server | null = null;
176
- private clients = new Set<ServerResponse>();
185
+ /**
186
+ * Live SSE connections → the mesh device id each one claimed on connect
187
+ * (undefined for clients that didn't claim one: desktop UIs, and companion
188
+ * builds from before the claim existed). The claim is what makes
189
+ * `sendToDevice` addressable rather than a shout.
190
+ */
191
+ private clients = new Map<ServerResponse, string | undefined>();
177
192
  private pingTimer: ReturnType<typeof setInterval> | undefined;
178
193
  private port = 0;
179
194
  private tlsIdentity: BridgeTlsIdentity | null = null;
@@ -185,6 +200,9 @@ export class BridgeServer {
185
200
  host: string;
186
201
  port: number;
187
202
  token?: string;
203
+ /** Origins permitted to call the bridge from a browser. Empty by
204
+ * default: native clients send no Origin and need no entry here. */
205
+ allowedOrigins?: readonly string[];
188
206
  startedAt: string;
189
207
  /**
190
208
  * When present, the bridge serves HTTPS with this identity. A provider
@@ -213,8 +231,50 @@ export class BridgeServer {
213
231
  /** Push an event to every connected SSE client. */
214
232
  broadcast(event: BridgeEvent): void {
215
233
  if (this.clients.size === 0) return;
234
+ this.write(this.clients.keys(), event);
235
+ }
236
+
237
+ /**
238
+ * Push an event to the client(s) that claimed `deviceId` — the delivery
239
+ * path for anything addressed to ONE device.
240
+ *
241
+ * Device commands are not public: their params carry one-time transfer
242
+ * tokens, exec command lines, remote paths, and — on the chunked fallback —
243
+ * whole base64 file bodies. Broadcasting them handed every connected client
244
+ * another device's secrets and relied on each client discarding what wasn't
245
+ * addressed to it, which is courtesy, not enforcement.
246
+ *
247
+ * A claim is an ADDRESS, not a credential: any client holding the bridge
248
+ * token could claim any id, and the bridge token is (still) the only trust
249
+ * boundary here. What this buys is that a device no longer passively
250
+ * receives traffic meant for its peers.
251
+ *
252
+ * Clients that claimed nothing are the fallback audience, and only when the
253
+ * target claimed nothing either: a companion build that predates the claim
254
+ * can't be addressed, and dropping its commands would take the mesh offline
255
+ * for it. So an updated device's traffic never reaches them — the fallback
256
+ * shrinks to nothing as the fleet updates.
257
+ */
258
+ sendToDevice(deviceId: string, event: BridgeEvent): void {
259
+ if (this.clients.size === 0) return;
260
+ const claimed: ServerResponse[] = [];
261
+ const unclaimed: ServerResponse[] = [];
262
+ for (const [res, id] of this.clients) {
263
+ if (id === deviceId) claimed.push(res);
264
+ else if (id === undefined) unclaimed.push(res);
265
+ }
266
+ if (claimed.length === 0) {
267
+ logDebug(
268
+ "native",
269
+ `No SSE client claims device ${deviceId} — delivering to ${unclaimed.length} unclaimed client(s)`,
270
+ );
271
+ }
272
+ this.write(claimed.length > 0 ? claimed : unclaimed, event);
273
+ }
274
+
275
+ private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
216
276
  const payload = `data: ${JSON.stringify(event)}\n\n`;
217
- for (const res of this.clients) {
277
+ for (const res of targets) {
218
278
  try {
219
279
  res.write(payload);
220
280
  } catch {
@@ -245,7 +305,7 @@ export class BridgeServer {
245
305
  : createServer(onRequest);
246
306
 
247
307
  this.pingTimer = setInterval(() => {
248
- for (const res of this.clients) {
308
+ for (const res of this.clients.keys()) {
249
309
  try {
250
310
  res.write(": ping\n\n");
251
311
  } catch {
@@ -301,7 +361,7 @@ export class BridgeServer {
301
361
 
302
362
  async stop(): Promise<void> {
303
363
  clearInterval(this.pingTimer);
304
- for (const res of this.clients) {
364
+ for (const res of this.clients.keys()) {
305
365
  try {
306
366
  res.end();
307
367
  } catch {
@@ -329,6 +389,29 @@ export class BridgeServer {
329
389
  const path = url.pathname;
330
390
  const method = req.method ?? "GET";
331
391
 
392
+ // Origin / Host guard runs before everything, including OPTIONS: a
393
+ // preflight that answers 204 to any origin is itself the permission
394
+ // slip the browser is asking for.
395
+ const origin =
396
+ typeof req.headers.origin === "string" ? req.headers.origin : undefined;
397
+ const refusal = this.originGuard(req);
398
+ if (refusal !== undefined) {
399
+ res.writeHead(403, {
400
+ ...this.corsHeaders(),
401
+ "Content-Type": "application/json",
402
+ });
403
+ res.end(JSON.stringify({ ok: false, error: refusal }));
404
+ return;
405
+ }
406
+ // Set once here rather than in corsHeaders(): setHeader values survive
407
+ // every later writeHead(code, {...}) that does not name the same key,
408
+ // so each of the ~8 response sites keeps the grant without threading
409
+ // the origin through all of them.
410
+ if (origin !== undefined && this.isAllowedOrigin(origin)) {
411
+ res.setHeader("Access-Control-Allow-Origin", origin);
412
+ res.setHeader("Vary", "Origin");
413
+ }
414
+
332
415
  if (method === "OPTIONS") {
333
416
  res.writeHead(204, this.corsHeaders());
334
417
  res.end();
@@ -425,7 +508,10 @@ export class BridgeServer {
425
508
  }
426
509
 
427
510
  try {
428
- if (method === "GET" && path === "/events") return this.openStream(res);
511
+ // A mesh client names itself here so device-addressed events reach it
512
+ // alone (see sendToDevice); UI clients simply omit it.
513
+ if (method === "GET" && path === "/events")
514
+ return this.openStream(res, deviceIdParam(url));
429
515
 
430
516
  if (method === "GET" && path === "/chats")
431
517
  return this.json(res, 200, { chats: this.handlers.listChats() });
@@ -549,12 +635,15 @@ export class BridgeServer {
549
635
  const token = url.searchParams.get("transfer") ?? "";
550
636
  if (!token)
551
637
  return this.json(res, 400, { ok: false, error: "transfer required" });
638
+ // The caller names itself so the token's device binding can be
639
+ // checked (see core/mesh/transfers.ts take()).
640
+ const from = deviceIdParam(url);
552
641
  if (method === "POST") {
553
- const result = await this.handlers.acceptFileUpload(token, req);
642
+ const result = await this.handlers.acceptFileUpload(token, req, from);
554
643
  return this.json(res, result.ok ? 200 : 409, result);
555
644
  }
556
645
  if (method === "GET") {
557
- const file = await this.handlers.openFileDownload(token);
646
+ const file = await this.handlers.openFileDownload(token, from);
558
647
  if (!file)
559
648
  return this.json(res, 404, {
560
649
  ok: false,
@@ -725,7 +814,7 @@ export class BridgeServer {
725
814
  }
726
815
  }
727
816
 
728
- private openStream(res: ServerResponse): void {
817
+ private openStream(res: ServerResponse, deviceId?: string): void {
729
818
  res.writeHead(200, {
730
819
  ...this.corsHeaders(),
731
820
  "Content-Type": "text/event-stream",
@@ -752,8 +841,11 @@ export class BridgeServer {
752
841
  } catch (err) {
753
842
  logError("native", "Failed to replay live turn to new client", err);
754
843
  }
755
- this.clients.add(res);
756
- logDebug("native", `SSE client connected (${this.clients.size} total)`);
844
+ this.clients.set(res, deviceId);
845
+ logDebug(
846
+ "native",
847
+ `SSE client connected${deviceId ? ` as device ${deviceId}` : ""} (${this.clients.size} total)`,
848
+ );
757
849
  res.on("close", () => {
758
850
  this.clients.delete(res);
759
851
  logDebug("native", `SSE client left (${this.clients.size} total)`);
@@ -824,9 +916,17 @@ export class BridgeServer {
824
916
  );
825
917
  }
826
918
 
919
+ /**
920
+ * CORS headers.
921
+ *
922
+ * Deliberately NOT `Access-Control-Allow-Origin: *`. The bridge's clients
923
+ * are native apps (Electron main process, Flutter, curl, talon-node),
924
+ * which send no `Origin` at all — a wildcard buys them nothing and hands
925
+ * every web page on the internet a readable cross-origin channel to the
926
+ * agent API. Only an explicitly configured origin is echoed back.
927
+ */
827
928
  private corsHeaders(): Record<string, string> {
828
929
  return {
829
- "Access-Control-Allow-Origin": "*",
830
930
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
831
931
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
832
932
  "Access-Control-Max-Age": "86400",
@@ -835,6 +935,55 @@ export class BridgeServer {
835
935
  };
836
936
  }
837
937
 
938
+ /** True when `origin` is on the operator's `native.allowedOrigins` list. */
939
+ private isAllowedOrigin(origin: string): boolean {
940
+ return this.opts.allowedOrigins?.includes(origin) ?? false;
941
+ }
942
+
943
+ /**
944
+ * Reject browser-driven cross-origin requests and DNS-rebinding.
945
+ *
946
+ * Two independent checks, because they stop different attacks:
947
+ *
948
+ * - `Origin`: browsers attach it to every cross-origin request and
949
+ * scripts cannot forge it. Native clients omit it entirely. So "an
950
+ * Origin we did not allow" means "a web page is driving us" — which,
951
+ * on the default unauthenticated loopback bind, would let any site
952
+ * the user visits POST /send and run tools on this machine.
953
+ * - `Host`: a name that resolves to 127.0.0.1 makes the request
954
+ * SAME-origin, so no Origin header is sent and the check above never
955
+ * fires. Pinning Host to loopback/the configured bind closes that.
956
+ *
957
+ * Returns an error string when the request must be refused.
958
+ */
959
+ private originGuard(req: IncomingMessage): string | undefined {
960
+ const origin = req.headers.origin;
961
+ if (typeof origin === "string" && origin !== "" && origin !== "null") {
962
+ if (!this.isAllowedOrigin(origin)) {
963
+ return `Origin ${origin} is not allowed. Add it to native.allowedOrigins to permit browser clients.`;
964
+ }
965
+ }
966
+
967
+ const host = req.headers.host;
968
+ if (typeof host === "string" && host !== "") {
969
+ // Strip the port; bracketed IPv6 keeps its brackets off.
970
+ const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
971
+ const allowed =
972
+ name === "127.0.0.1" ||
973
+ name === "localhost" ||
974
+ name === "::1" ||
975
+ name === this.opts.host ||
976
+ // A wildcard bind is reachable under every local name; the bearer
977
+ // token is the control there, not the Host header.
978
+ this.opts.host === "0.0.0.0" ||
979
+ this.opts.host === "::";
980
+ if (!allowed) {
981
+ return `Host ${host} is not recognised for this bridge (DNS-rebinding guard).`;
982
+ }
983
+ }
984
+ return undefined;
985
+ }
986
+
838
987
  private jsonHeaders(): Record<string, string> {
839
988
  return { ...this.corsHeaders(), "Content-Type": "application/json" };
840
989
  }
@@ -880,6 +1029,16 @@ function asString(v: unknown): string | undefined {
880
1029
  return typeof v === "string" ? v : undefined;
881
1030
  }
882
1031
 
1032
+ /**
1033
+ * The `deviceId` a mesh client claims on `/events` and `/devices/file`.
1034
+ * Undefined when absent or blank — every consumer treats "no claim" as the
1035
+ * legacy case, so an empty string must never look like a claimed id.
1036
+ */
1037
+ function deviceIdParam(url: URL): string | undefined {
1038
+ const raw = (url.searchParams.get("deviceId") ?? "").trim();
1039
+ return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
1040
+ }
1041
+
883
1042
  /** Parse a positive-integer query param; undefined when absent/invalid. */
884
1043
  function asPositiveInt(v: string | null): number | undefined {
885
1044
  if (!v) return undefined;
@@ -38,6 +38,12 @@ export function formatTokenCount(n: number): string {
38
38
  return String(n);
39
39
  }
40
40
 
41
+ /** Keep small model costs legible without making larger totals noisy. */
42
+ export function formatUsd(cost: number): string {
43
+ const safeCost = Number.isFinite(cost) && cost > 0 ? cost : 0;
44
+ return `$${safeCost > 0.5 ? safeCost.toFixed(2) : safeCost.toFixed(4)}`;
45
+ }
46
+
41
47
  export function formatBytes(bytes: number): string {
42
48
  if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
43
49
  if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
@@ -18,6 +18,99 @@ import {
18
18
  import { sendText, toPositiveId } from "./shared.js";
19
19
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
20
20
 
21
+ // ── Inline keyboards ─────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Telegram caps `callback_data` at 64 BYTES, not characters
25
+ * (https://core.telegram.org/bots/api#inlinekeyboardbutton). Exceeding it
26
+ * fails the whole sendMessage with BUTTON_DATA_INVALID — the message never
27
+ * arrives, buttons and all.
28
+ *
29
+ * Bytes rather than chars matters: a 21-character Japanese label is already
30
+ * over budget, so non-Latin keyboards break far sooner than English ones.
31
+ */
32
+ const CALLBACK_DATA_MAX_BYTES = 64;
33
+
34
+ type ButtonSpec = { text: string; url?: string; callback_data?: string };
35
+
36
+ /** The two inline-button shapes this module emits. */
37
+ type BuiltButton =
38
+ { text: string; url: string } | { text: string; callback_data: string };
39
+
40
+ /** Byte length of `text` when UTF-8 encoded. */
41
+ function utf8Bytes(text: string): number {
42
+ return Buffer.byteLength(text, "utf8");
43
+ }
44
+
45
+ /** Longest prefix of `text` that fits `maxBytes`, never splitting a code point. */
46
+ function truncateUtf8(text: string, maxBytes: number): string {
47
+ if (utf8Bytes(text) <= maxBytes) return text;
48
+ let out = "";
49
+ let used = 0;
50
+ for (const char of text) {
51
+ const size = utf8Bytes(char);
52
+ if (used + size > maxBytes) break;
53
+ out += char;
54
+ used += size;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Resolve one button's callback data.
61
+ *
62
+ * An EXPLICIT `callback_data` is semantic — the model dispatches on the exact
63
+ * value it chose — so an over-long one is reported rather than truncated;
64
+ * handing the callback handler a different string than the model expects
65
+ * would be a silent behaviour change. The `text` fallback carries no such
66
+ * contract, so it is truncated to fit.
67
+ */
68
+ function callbackDataFor(
69
+ btn: ButtonSpec,
70
+ ): { data: string } | { error: string } {
71
+ if (btn.callback_data !== undefined) {
72
+ const bytes = utf8Bytes(btn.callback_data);
73
+ if (bytes > CALLBACK_DATA_MAX_BYTES) {
74
+ return {
75
+ error:
76
+ `callback_data for button "${btn.text}" is ${bytes} bytes; ` +
77
+ `Telegram allows at most ${CALLBACK_DATA_MAX_BYTES}. Use a short ` +
78
+ `token (e.g. "opt_a") and keep the wording in the button text.`,
79
+ };
80
+ }
81
+ return { data: btn.callback_data };
82
+ }
83
+ return { data: truncateUtf8(btn.text, CALLBACK_DATA_MAX_BYTES) };
84
+ }
85
+
86
+ /**
87
+ * Build an inline keyboard from button rows, enforcing the callback_data
88
+ * cap. Returns an error message instead of a keyboard when the model
89
+ * supplied explicit data that cannot be sent.
90
+ *
91
+ * Exported as a unit seam — every button path in this module routes
92
+ * through it, so the cap is testable without a live Bot.
93
+ */
94
+ export function buildInlineKeyboard(
95
+ rows: ButtonSpec[][],
96
+ ): { keyboard: BuiltButton[][] } | { error: string } {
97
+ const keyboard: BuiltButton[][] = [];
98
+ for (const row of rows) {
99
+ const built: BuiltButton[] = [];
100
+ for (const btn of row) {
101
+ if (btn.url) {
102
+ built.push({ text: btn.text, url: btn.url });
103
+ continue;
104
+ }
105
+ const resolved = callbackDataFor(btn);
106
+ if ("error" in resolved) return { error: resolved.error };
107
+ built.push({ text: btn.text, callback_data: resolved.data });
108
+ }
109
+ keyboard.push(built);
110
+ }
111
+ return { keyboard };
112
+ }
113
+
21
114
  // ── Scheduled sends (persistent) ─────────────────────────────────────────────
22
115
 
23
116
  /** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
@@ -26,23 +119,29 @@ const MAX_DELAY_SEC = 24 * 60 * 60;
26
119
  /** Deliver one scheduled entry (shared by the live timer and restore). */
27
120
  async function fireScheduled(bot: Bot, entry: ScheduledMessage): Promise<void> {
28
121
  const chatId = Number(entry.chatId);
122
+ // `replyTo` is stored in the owning frontend's native shape (Discord keeps
123
+ // snowflake strings there); Telegram's API wants a number.
124
+ const replyTo = toPositiveId(entry.replyTo);
29
125
  if (entry.rows) {
30
- const keyboard = entry.rows.map((row) =>
31
- row.map((btn) =>
32
- btn.url
33
- ? { text: btn.text, url: btn.url }
34
- : { text: btn.text, callback_data: btn.callback_data ?? btn.text },
35
- ),
36
- );
126
+ // Validated at schedule time; rebuilt here so entries persisted by an
127
+ // older build (or edited on disk) still can't fail the send.
128
+ const built = buildInlineKeyboard(entry.rows);
129
+ if ("error" in built) {
130
+ logWarn(
131
+ "bot",
132
+ `Scheduled message ${entry.id} has bad buttons: ${built.error}`,
133
+ );
134
+ await sendText(bot, chatId, entry.text, replyTo);
135
+ return;
136
+ }
137
+ const keyboard = built.keyboard;
37
138
  await bot.api.sendMessage(chatId, markdownToTelegramHtml(entry.text), {
38
139
  parse_mode: "HTML",
39
140
  reply_markup: { inline_keyboard: keyboard },
40
- reply_parameters: entry.replyTo
41
- ? { message_id: entry.replyTo }
42
- : undefined,
141
+ reply_parameters: replyTo ? { message_id: replyTo } : undefined,
43
142
  });
44
143
  } else {
45
- await sendText(bot, chatId, entry.text, entry.replyTo);
144
+ await sendText(bot, chatId, entry.text, replyTo);
46
145
  }
47
146
  }
48
147
 
@@ -211,20 +310,11 @@ export const messagingHandlers: TelegramActionHandlers = {
211
310
  if (text.length > TELEGRAM_MAX_TEXT)
212
311
  return { ok: false, error: `Text too long` };
213
312
  const html = markdownToTelegramHtml(text);
214
- const rows = body.rows as Array<
215
- Array<{ text: string; url?: string; callback_data?: string }>
216
- >;
313
+ const rows = body.rows as ButtonSpec[][];
314
+ const built = buildInlineKeyboard(rows);
315
+ if ("error" in built) return { ok: false, error: built.error };
316
+ const keyboard = built.keyboard;
217
317
  gateway.incrementMessages(chatId);
218
- const keyboard = rows.map((row) =>
219
- row.map((btn) =>
220
- btn.url
221
- ? { text: btn.text, url: btn.url }
222
- : {
223
- text: btn.text,
224
- callback_data: btn.callback_data ?? btn.text,
225
- },
226
- ),
227
- );
228
318
  try {
229
319
  const sent = await bot.api.sendMessage(chatId, html, {
230
320
  parse_mode: "HTML",
@@ -243,6 +333,12 @@ export const messagingHandlers: TelegramActionHandlers = {
243
333
  const text = String(body.text ?? "");
244
334
  const replyTo = toPositiveId(body.reply_to_message_id);
245
335
  const rows = body.rows as ScheduledMessage["rows"];
336
+ // Validate buttons now rather than at fire time — a rejection minutes
337
+ // later, with the turn long over, is invisible to the model.
338
+ if (rows) {
339
+ const built = buildInlineKeyboard(rows);
340
+ if ("error" in built) return { ok: false, error: built.error };
341
+ }
246
342
  // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
247
343
  // propagate: setTimeout(fn, NaN) fires immediately.
248
344
  const requested = Number(body.delay_seconds ?? 60);
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Context } from "grammy";
10
- import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
10
+ import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
11
11
  import {
12
12
  renderMetricsKeyboard,
13
13
  renderMetricsPanel,
@@ -24,7 +24,7 @@ import {
24
24
  } from "../helpers/index.js";
25
25
  import { collectDoctorReport } from "../../../core/doctor.js";
26
26
  import { handleAdminCommand } from "../admin.js";
27
- import { getTodayMetrics } from "../../../util/metrics.js";
27
+ import { getTodayMetrics } from "../../../storage/metrics.js";
28
28
  import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
29
29
  import { telegramCommandMenu } from "./definitions.js";
30
30
 
@@ -25,11 +25,67 @@ export function escapeHtml(text: string): string {
25
25
  return escapeNative(text);
26
26
  }
27
27
 
28
+ /**
29
+ * True when every tag in `html` is closed in the order it was opened.
30
+ *
31
+ * The inline formatters below are independent regex passes with no
32
+ * knowledge of each other's spans, so interleaved delimiters
33
+ * (`**a _b** c_`) emit crossed tags like `<b><i>x</b></i>`. Telegram
34
+ * refuses to parse those and 400s the whole message, so the caller
35
+ * checks before committing to the formatted rendering.
36
+ *
37
+ * Only tags this module generates are possible here — everything else
38
+ * was entity-escaped in step 3 — so a plain stack is sufficient.
39
+ */
40
+ function isWellFormedHtml(html: string): boolean {
41
+ const stack: string[] = [];
42
+ const tag = /<(\/?)([a-zA-Z]+)(?:\s[^>]*)?>/g;
43
+ let match: RegExpExecArray | null;
44
+ while ((match = tag.exec(html)) !== null) {
45
+ const [, closing, name] = match;
46
+ if (closing) {
47
+ if (stack.pop() !== name) return false;
48
+ } else {
49
+ stack.push(name!);
50
+ }
51
+ }
52
+ return stack.length === 0;
53
+ }
54
+
55
+ /** Apply the inline delimiter passes to already-escaped text. */
56
+ function applyInlineFormatting(input: string): string {
57
+ let out = input;
58
+ // Bold+italic: ***text*** — must run before the ** and * passes, which
59
+ // would otherwise split it into the crossed pair `<b><i>x</b></i>`.
60
+ out = out.replace(/\*\*\*(.+?)\*\*\*/g, "<b><i>$1</i></b>");
61
+ // Bold: **text**
62
+ out = out.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
63
+ // Italic: *text* (not preceded by another *)
64
+ out = out.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
65
+ // Italic: _text_ (surrounded by non-word or start/end)
66
+ out = out.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
67
+ // Links: [text](url) — only safe URL schemes become anchors. Both text
68
+ // and url were already HTML-escaped by step 3 (quotes included, so the
69
+ // href attribute can't be broken out of); escaping again here corrupted
70
+ // every & in a query string into &amp;amp;.
71
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
72
+ /^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
73
+ );
74
+ // Strikethrough: ~~text~~
75
+ out = out.replace(/~~(.+?)~~/g, "<s>$1</s>");
76
+ return out;
77
+ }
78
+
28
79
  /**
29
80
  * Convert Markdown output to Telegram-safe HTML.
30
81
  *
31
82
  * Handles: bold, italic, inline code, fenced code blocks, links.
32
83
  * Escapes HTML entities in non-formatted text.
84
+ *
85
+ * Guarantees well-formed output: when the inline passes would produce
86
+ * crossed tags, the formatting is dropped rather than emitted, because
87
+ * Telegram rejects the entire message on a parse error and the caller's
88
+ * only recourse is a second round-trip with no formatting at all.
33
89
  */
34
90
  export function markdownToTelegramHtml(text: string): string {
35
91
  // Step 1: Extract fenced code blocks to avoid processing their contents.
@@ -59,25 +115,14 @@ export function markdownToTelegramHtml(text: string): string {
59
115
  // oxlint-disable-next-line no-control-regex
60
116
  processed = processed.replace(/[^`\x00]+/g, (segment) => escapeHtml(segment));
61
117
 
62
- // Step 4: Apply inline formatting.
63
- // Bold: **text**
64
- processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
65
- // Italic: *text* (not preceded by another *)
66
- processed = processed.replace(
67
- /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g,
68
- "<i>$1</i>",
69
- );
70
- // Italic: _text_ (surrounded by non-word or start/end)
71
- processed = processed.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
72
- // Links: [text](url) — only safe URL schemes become anchors. Both text
73
- // and url were already HTML-escaped by step 3 (quotes included, so the
74
- // href attribute can't be broken out of); escaping again here corrupted
75
- // every & in a query string into &amp;amp;.
76
- processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
77
- /^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
78
- );
79
- // Strikethrough: ~~text~~
80
- processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
118
+ // Step 4: Apply inline formatting, but only keep it if the result is
119
+ // actually parseable. `processed` at this point holds escaped text plus
120
+ // tag-free placeholders, so it is a safe unformatted fallback.
121
+ const unformatted = processed;
122
+ processed = applyInlineFormatting(processed);
123
+ if (!isWellFormedHtml(processed)) {
124
+ processed = unformatted;
125
+ }
81
126
 
82
127
  // Steps 5+6: Restore code spans and fenced blocks. The replacement MUST
83
128
  // go through a function: with a string, String.replace interprets $-