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.
- package/README.md +1 -1
- package/package.json +1 -2
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -1
- package/src/backend/kilo/handler/index.ts +1 -2
- package/src/backend/kilo/handler/state.ts +3 -8
- package/src/backend/kilo/index.ts +2 -28
- package/src/backend/openai-agents/handler/message.ts +1 -1
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/handler/index.ts +1 -2
- package/src/backend/opencode/handler/state.ts +3 -8
- package/src/backend/opencode/index.ts +0 -7
- package/src/backend/shared/delivery.ts +1 -1
- package/src/backend/shared/handle-retry.ts +1 -1
- package/src/backend/shared/index.ts +3 -2
- package/src/backend/shared/turn-interrupt.ts +1 -1
- package/src/cli/index.ts +0 -8
- package/src/core/bus/index.ts +1 -9
- package/src/core/mesh/registry.ts +106 -4
- package/src/core/mesh/service.ts +22 -7
- package/src/core/mesh/transfers.ts +33 -4
- package/src/core/plugin/index.ts +0 -9
- package/src/core/prompt/index.ts +1 -16
- package/src/core/vfs/index.ts +2 -21
- package/src/core/weaver/index.ts +5 -9
- package/src/frontend/discord/actions/messaging.ts +73 -13
- package/src/frontend/discord/actions/shared.ts +21 -3
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/errors.ts +16 -5
- package/src/frontend/discord/formatting.ts +19 -4
- package/src/frontend/native/index.ts +22 -8
- package/src/frontend/native/protocol.ts +6 -0
- package/src/frontend/native/server.ts +171 -12
- package/src/frontend/shared/format.ts +6 -0
- package/src/frontend/telegram/actions/messaging.ts +120 -24
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +64 -19
- package/src/frontend/terminal/commands.ts +46 -8
- package/src/frontend/terminal/input.ts +110 -12
- package/src/frontend/terminal/renderer.ts +2 -0
- package/src/{util → storage}/metrics.ts +1 -1
- package/src/storage/scheduled-store.ts +6 -3
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/backend/codex/index.ts +0 -33
- package/src/backend/openai-agents/index.ts +0 -48
- package/src/cli/plugin-entries.ts +0 -104
- package/src/core/agent-runtime/index.ts +0 -62
- package/src/core/background/index.ts +0 -21
- package/src/core/engine/index.ts +0 -20
- package/src/core/models/index.ts +0 -17
- package/src/core/soul/index.ts +0 -140
- package/src/core/tools/mcp-server.ts +0 -111
- /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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
756
|
-
logDebug(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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,
|
|
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
|
|
215
|
-
|
|
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);
|
|
@@ -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 "../../../
|
|
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;.
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
processed = processed
|
|
67
|
-
|
|
68
|
-
|
|
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;.
|
|
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 $-
|