talon-agent 3.33.4 → 3.34.1

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 (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tool-result summarisation — the bounded, readable string the app shows in
3
+ * its expanded tool view (and the turn-meta sidecar persists).
4
+ */
5
+
6
+ /** Best-effort JSON stringify that never throws (circular refs → String()). */
7
+ function safeJson(value: unknown): string {
8
+ try {
9
+ return JSON.stringify(value, null, 2);
10
+ } catch {
11
+ return String(value);
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Reduce a tool's raw result to a readable, bounded string for the app's
17
+ * expanded tool view. MCP results are typically
18
+ * `{ content: [{ type: "text", text }] }`, so pull the text parts out; fall
19
+ * back to pretty JSON for anything else. Truncated so a huge file read or
20
+ * search dump can't bloat the wire payload or the history sidecar.
21
+ */
22
+ export function summarizeToolResult(result: unknown): string | undefined {
23
+ if (result == null) return undefined;
24
+ let text: string;
25
+ if (typeof result === "string") {
26
+ text = result;
27
+ } else {
28
+ const content = (result as { content?: unknown }).content;
29
+ if (Array.isArray(content)) {
30
+ const parts = content
31
+ .map((c) =>
32
+ c &&
33
+ typeof c === "object" &&
34
+ typeof (c as { text?: unknown }).text === "string"
35
+ ? (c as { text: string }).text
36
+ : "",
37
+ )
38
+ .filter((s) => s.length > 0);
39
+ text = parts.length > 0 ? parts.join("\n") : safeJson(result);
40
+ } else {
41
+ text = safeJson(result);
42
+ }
43
+ }
44
+ text = text.trim();
45
+ if (!text) return undefined;
46
+ const MAX = 4000;
47
+ return text.length > MAX ? `${text.slice(0, MAX)}\n… (truncated)` : text;
48
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * A user turn — drive `dispatcher.execute()` and forward the canonical
3
+ * `AgentEvent` stream to clients as Bridge events: reasoning + tool activity
4
+ * stream live, while the persisted reply arrives via the gateway action
5
+ * handler (tool-only backends) or the trailing-prose fallback (text-mode
6
+ * backends) — mirroring the terminal renderer's delivery semantics.
7
+ */
8
+
9
+ import type { ExecuteResult } from "../../core/types.js";
10
+ import type { AgentEvent } from "../../core/agent-runtime/events.js";
11
+ import { execute } from "../../core/engine/dispatcher.js";
12
+ import { toolInputToRecord } from "../../core/agent-runtime/events.js";
13
+ import { getBackendForChat } from "../../core/engine/backend-controller/index.js";
14
+ import { isDeliveryTool } from "../../core/tools/index.js";
15
+ import { recordTurnMeta } from "./turn-meta.js";
16
+ import type { ChatEntry } from "./chats.js";
17
+ import { refreshContext } from "./context.js";
18
+ import { emitAssistant, emitUser } from "./emit.js";
19
+ import type { BridgeEvent } from "./protocol.js";
20
+ import { takeQueued } from "./queue.js";
21
+ import type { LiveToolEntry, NativeRuntime } from "./runtime.js";
22
+ import { summarizeToolResult } from "./tool-result.js";
23
+
24
+ /** The tool calls of one running turn, in call order. */
25
+ type TurnTools = Map<string, LiveToolEntry>;
26
+
27
+ export type StartTurnOptions = { imagePath?: string; attachmentPath?: string };
28
+
29
+ /** True when a turn is currently running for a chat (used to decide queue). */
30
+ export function isBusy(runtime: NativeRuntime, chatId: string): boolean {
31
+ return runtime.liveTurns.has(chatId);
32
+ }
33
+
34
+ /**
35
+ * Events that reconstruct every in-progress turn for a freshly-connected
36
+ * client: a turn_start (so the live turn renders), a typing indicator, and
37
+ * each tool's call — plus its result when it has already finished. Replayed
38
+ * right after `hello` so a reconnect mid-turn shows the full tool timeline
39
+ * immediately rather than only the tools that happen to fire afterwards.
40
+ */
41
+ export function liveTurnEvents(runtime: NativeRuntime): BridgeEvent[] {
42
+ const events: BridgeEvent[] = [];
43
+ for (const [chatId, tools] of runtime.liveTurns) {
44
+ events.push({ kind: "turn_start", chatId });
45
+ events.push({ kind: "typing", chatId, on: true });
46
+ for (const { call, done } of tools.values()) {
47
+ events.push({
48
+ kind: "tool",
49
+ chatId,
50
+ id: call.id,
51
+ name: call.name,
52
+ phase: "call",
53
+ ...(call.input ? { input: call.input } : {}),
54
+ });
55
+ if (done) {
56
+ events.push({
57
+ kind: "tool",
58
+ chatId,
59
+ id: call.id,
60
+ name: call.name,
61
+ phase: "result",
62
+ ...(call.error ? { error: call.error } : {}),
63
+ ...(call.output ? { output: call.output } : {}),
64
+ });
65
+ }
66
+ }
67
+ }
68
+ return events;
69
+ }
70
+
71
+ /**
72
+ * Safety net: any tool the backend announced but never resolved (a crash can
73
+ * eat a tool_result; callback backends historically never emitted one —
74
+ * handler-to-events now pairs each tool_call with an immediate synthetic
75
+ * result) gets a synthetic result at turn end — a spinner the app opened on
76
+ * phase:"call" must always see a phase:"result".
77
+ */
78
+ function flushOpenTools(
79
+ runtime: NativeRuntime,
80
+ entry: ChatEntry,
81
+ turnTools: TurnTools,
82
+ ): void {
83
+ for (const [id, live] of turnTools) {
84
+ if (live.done) continue;
85
+ live.done = true;
86
+ live.call.durationMs = Date.now() - live.startedAt;
87
+ runtime.broadcast({
88
+ kind: "tool",
89
+ chatId: entry.id,
90
+ id,
91
+ name: live.call.name,
92
+ phase: "result",
93
+ });
94
+ }
95
+ }
96
+
97
+ /** Forward one agent event to clients, recording tool activity as it goes. */
98
+ function forwardAgentEvent(
99
+ runtime: NativeRuntime,
100
+ entry: ChatEntry,
101
+ turnTools: TurnTools,
102
+ event: AgentEvent,
103
+ ): void {
104
+ switch (event.type) {
105
+ case "reasoning":
106
+ if (event.text)
107
+ runtime.broadcast({
108
+ kind: "reasoning",
109
+ chatId: entry.id,
110
+ text: event.text,
111
+ });
112
+ break;
113
+ case "text_delta":
114
+ runtime.broadcast({ kind: "delta", chatId: entry.id, text: event.text });
115
+ break;
116
+ case "tool_call": {
117
+ // Delivery plumbing (end_turn / send_message / react) never
118
+ // enters the tool timeline — its effect arrives as the
119
+ // `message`/reaction itself. Skipping here keeps the live
120
+ // stream, the mid-turn replay to late joiners, and the
121
+ // persisted turn meta consistent from one choke point.
122
+ if (isDeliveryTool(event.name)) break;
123
+ const input = toolInputToRecord(event.name, event.input);
124
+ turnTools.set(event.id, {
125
+ call: { id: event.id, name: event.name, input },
126
+ startedAt: Date.now(),
127
+ });
128
+ runtime.broadcast({
129
+ kind: "tool",
130
+ chatId: entry.id,
131
+ id: event.id,
132
+ name: event.name,
133
+ phase: "call",
134
+ input,
135
+ });
136
+ break;
137
+ }
138
+ case "tool_result": {
139
+ if (isDeliveryTool(event.name)) break;
140
+ const output = summarizeToolResult(event.result);
141
+ const live = turnTools.get(event.id);
142
+ if (live) {
143
+ live.done = true;
144
+ live.call.durationMs = Date.now() - live.startedAt;
145
+ if (event.error) live.call.error = event.error;
146
+ if (output) live.call.output = output;
147
+ }
148
+ runtime.broadcast({
149
+ kind: "tool",
150
+ chatId: entry.id,
151
+ id: event.id,
152
+ name: event.name,
153
+ phase: "result",
154
+ ...(event.error ? { error: event.error } : {}),
155
+ ...(output ? { output } : {}),
156
+ });
157
+ break;
158
+ }
159
+ case "error":
160
+ runtime.broadcast({
161
+ kind: "error",
162
+ chatId: entry.id,
163
+ message: event.error.message,
164
+ });
165
+ break;
166
+ }
167
+ }
168
+
169
+ /** Settle a completed turn: deliver trailing prose, persist meta, close it. */
170
+ function finishTurn(
171
+ runtime: NativeRuntime,
172
+ entry: ChatEntry,
173
+ turnTools: TurnTools,
174
+ result: ExecuteResult,
175
+ ): void {
176
+ // Trailing-prose fallback: text-mode backends (kilo/opencode/codex)
177
+ // deliver the reply as plain text rather than a bridge send. When no
178
+ // delivery tool fired this turn, surface result.text as the message —
179
+ // exactly what the terminal renderer does.
180
+ let delivered = result.bridgeMessageCount;
181
+ if (delivered === 0 && result.text.trim()) {
182
+ emitAssistant(runtime, entry, result.text.trim());
183
+ delivered = 1;
184
+ }
185
+
186
+ // Resolve open tool spinners BEFORE persisting turn meta, so the
187
+ // synthetic durations land in the recorded timeline too.
188
+ flushOpenTools(runtime, entry, turnTools);
189
+
190
+ // Persist what this turn did against its final assistant message, so
191
+ // reloaded history keeps the tool timeline + stats footer. Only when
192
+ // the turn actually delivered — otherwise the "last assistant id"
193
+ // belongs to a previous turn and the meta would land on the wrong row.
194
+ if (delivered > 0) {
195
+ const msgId = runtime.lastAssistantId.get(entry.id);
196
+ if (msgId) {
197
+ const tools = [...turnTools.values()].map((t) => t.call);
198
+ recordTurnMeta(entry.id, msgId, {
199
+ durationMs: result.durationMs,
200
+ tokensIn: result.inputTokens,
201
+ tokensOut: result.outputTokens,
202
+ ...(tools.length ? { tools } : {}),
203
+ });
204
+ }
205
+ }
206
+
207
+ runtime.broadcast({ kind: "typing", chatId: entry.id, on: false });
208
+ runtime.broadcast({
209
+ kind: "turn_end",
210
+ chatId: entry.id,
211
+ delivered,
212
+ durationMs: result.durationMs,
213
+ usage: { input: result.inputTokens, output: result.outputTokens },
214
+ });
215
+
216
+ // Refresh the live context-window readout now the turn's usage has
217
+ // settled, and push it to clients via chat_updated. Best-effort — a
218
+ // failure here must never surface as a turn error.
219
+ void refreshContext(runtime, entry).catch(() => {});
220
+ }
221
+
222
+ /** Close a turn that threw: flush spinners, surface the error, end it. */
223
+ function failTurn(
224
+ runtime: NativeRuntime,
225
+ entry: ChatEntry,
226
+ turnTools: TurnTools,
227
+ err: unknown,
228
+ start: number,
229
+ ): void {
230
+ flushOpenTools(runtime, entry, turnTools);
231
+ runtime.broadcast({ kind: "typing", chatId: entry.id, on: false });
232
+ runtime.broadcast({
233
+ kind: "error",
234
+ chatId: entry.id,
235
+ message: err instanceof Error ? err.message : String(err),
236
+ });
237
+ runtime.broadcast({
238
+ kind: "turn_end",
239
+ chatId: entry.id,
240
+ delivered: 0,
241
+ durationMs: Date.now() - start,
242
+ });
243
+ }
244
+
245
+ async function runTurn(
246
+ runtime: NativeRuntime,
247
+ entry: ChatEntry,
248
+ text: string,
249
+ messageId: number,
250
+ attachmentPath?: string,
251
+ ): Promise<void> {
252
+ const start = Date.now();
253
+ // Tool calls observed during this turn, in call order — recorded into the
254
+ // turn-meta sidecar at turn_end so history can replay the timeline. Also
255
+ // registered in `liveTurns` so a client connecting mid-turn can be replayed
256
+ // the activity so far (cleared in the `finally`).
257
+ const turnTools: TurnTools = new Map();
258
+ runtime.liveTurns.set(entry.id, turnTools);
259
+ // Point the model at an attached image so it can read the file itself.
260
+ const prompt = attachmentPath
261
+ ? `${text ? `${text}\n\n` : ""}[Attached image: ${attachmentPath}]`
262
+ : text;
263
+ try {
264
+ const result = await execute({
265
+ chatId: entry.id,
266
+ numericChatId: entry.numericId,
267
+ prompt,
268
+ senderName: "User",
269
+ isGroup: false,
270
+ // Give the model the user message's id so `[msg_id:N]` is present and
271
+ // react/reply/edit target the user's message — not the bot's.
272
+ messageId,
273
+ source: "message",
274
+ onEvent: async (event) =>
275
+ forwardAgentEvent(runtime, entry, turnTools, event),
276
+ });
277
+ finishTurn(runtime, entry, turnTools, result);
278
+ } catch (err) {
279
+ failTurn(runtime, entry, turnTools, err, start);
280
+ } finally {
281
+ // The turn is over (delivered or errored) — stop replaying it to new
282
+ // clients. Any late tool spinner has already been flushed above.
283
+ runtime.liveTurns.delete(entry.id);
284
+ // Flush a queued follow-up as a fresh turn: "once it's done it will
285
+ // send". Deferred so we don't re-enter runTurn inside its own finally.
286
+ const queued = takeQueued(runtime, entry);
287
+ if (queued) {
288
+ setImmediate(() =>
289
+ startTurn(runtime, entry, queued.text, {
290
+ imagePath: queued.imagePath,
291
+ attachmentPath: queued.attachmentPath,
292
+ }),
293
+ );
294
+ }
295
+ }
296
+ }
297
+
298
+ /** Emit the user message, open the turn, and drive it. Shared by the /send
299
+ * handler and the queued-follow-up flush. */
300
+ export function startTurn(
301
+ runtime: NativeRuntime,
302
+ entry: ChatEntry,
303
+ text: string,
304
+ options?: StartTurnOptions,
305
+ ): void {
306
+ const messageId = emitUser(
307
+ runtime,
308
+ entry,
309
+ text,
310
+ options?.imagePath,
311
+ options?.attachmentPath,
312
+ );
313
+ runtime.broadcast({ kind: "turn_start", chatId: entry.id });
314
+ runtime.broadcast({ kind: "typing", chatId: entry.id, on: true });
315
+ void runTurn(runtime, entry, text, messageId, options?.attachmentPath);
316
+ }
317
+
318
+ /** Best-effort interrupt of a chat's in-flight turn. `true` if one was
319
+ * running and got signalled. */
320
+ export async function interruptTurn(
321
+ runtime: NativeRuntime,
322
+ chatId: string,
323
+ ): Promise<boolean> {
324
+ const entry = runtime.chats.get(chatId);
325
+ if (!entry) return false;
326
+ // Only meaningful while a turn is actually running.
327
+ if (!isBusy(runtime, chatId)) return false;
328
+ let backend = null;
329
+ try {
330
+ backend = getBackendForChat(chatId);
331
+ } catch {
332
+ return false;
333
+ }
334
+ const interrupt = backend?.chat?.interruptChatTurn;
335
+ if (!interrupt) return false;
336
+ try {
337
+ return await interrupt(chatId);
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
7
7
  import type { TalonConfig } from "../../util/config.js";
8
8
  import { files, dirs } from "../../util/paths.js";
9
9
  import { tailFile } from "../../util/tail-file.js";
10
- import { escapeHtml } from "./formatting.js";
10
+ import { escapeHtml, splitMessage } from "./formatting.js";
11
+ import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
11
12
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
12
13
  import { clearHistory } from "../../storage/history.js";
13
14
  import { todayLogDate } from "../../storage/daily-log.js";
@@ -22,6 +23,72 @@ import { getPulseStatus } from "../../core/background/pulse.js";
22
23
  import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
23
24
  import { formatDuration, formatModelLabel } from "./helpers/index.js";
24
25
 
26
+ /**
27
+ * Reply with an HTML listing, split across messages when it outgrows
28
+ * Telegram's 4096-char cap. The per-item listings below (chats, cron,
29
+ * pulse) scale with the daemon's chat count, and a single oversized
30
+ * `ctx.reply` fails the whole command with 400 "message is too long".
31
+ * Entries are `\n\n`-separated and each carries balanced tags, so the
32
+ * paragraph-first splitter never cuts through markup.
33
+ */
34
+ async function replyHtmlChunked(ctx: Context, text: string): Promise<void> {
35
+ for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
36
+ await ctx.reply(chunk, { parse_mode: "HTML" });
37
+ }
38
+ }
39
+
40
+ /** `/admin chats` — every active session, newest first, titled via getChat. */
41
+ async function replyActiveChats(
42
+ ctx: Context,
43
+ bot: Bot,
44
+ config: TalonConfig,
45
+ ): Promise<void> {
46
+ const sessions = getAllSessions();
47
+ if (sessions.length === 0) {
48
+ await ctx.reply("No active sessions.");
49
+ return;
50
+ }
51
+ sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
52
+
53
+ const titles = new Map<string, string>();
54
+ await Promise.all(
55
+ sessions.map(async (s) => {
56
+ try {
57
+ const id = parseInt(s.chatId, 10);
58
+ if (isNaN(id)) return;
59
+ const chat = await bot.api.getChat(id);
60
+ titles.set(
61
+ s.chatId,
62
+ "title" in chat
63
+ ? (chat.title ?? "DM")
64
+ : "first_name" in chat
65
+ ? (chat.first_name ?? "DM")
66
+ : "DM",
67
+ );
68
+ } catch {
69
+ /* inaccessible */
70
+ }
71
+ }),
72
+ );
73
+
74
+ const lines = sessions.map((s) => {
75
+ const age = s.info.lastActive
76
+ ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
77
+ : "?";
78
+ const title = titles.get(s.chatId) ?? s.chatId;
79
+ const model = formatModelLabel(
80
+ getChatSettings(s.chatId).model ?? config.model,
81
+ );
82
+ // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
83
+ // it gets the same escaping the title already had.
84
+ return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
85
+ });
86
+ await replyHtmlChunked(
87
+ ctx,
88
+ `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
89
+ );
90
+ }
91
+
25
92
  export async function handleAdminCommand(
26
93
  ctx: Context,
27
94
  bot: Bot,
@@ -32,52 +99,7 @@ export async function handleAdminCommand(
32
99
 
33
100
  switch (subcommand) {
34
101
  case "chats": {
35
- const sessions = getAllSessions();
36
- if (sessions.length === 0) {
37
- await ctx.reply("No active sessions.");
38
- return;
39
- }
40
- sessions.sort(
41
- (a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
42
- );
43
-
44
- const titles = new Map<string, string>();
45
- await Promise.all(
46
- sessions.map(async (s) => {
47
- try {
48
- const id = parseInt(s.chatId, 10);
49
- if (isNaN(id)) return;
50
- const chat = await bot.api.getChat(id);
51
- titles.set(
52
- s.chatId,
53
- "title" in chat
54
- ? (chat.title ?? "DM")
55
- : "first_name" in chat
56
- ? (chat.first_name ?? "DM")
57
- : "DM",
58
- );
59
- } catch {
60
- /* inaccessible */
61
- }
62
- }),
63
- );
64
-
65
- const lines = sessions.map((s) => {
66
- const age = s.info.lastActive
67
- ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
68
- : "?";
69
- const title = titles.get(s.chatId) ?? s.chatId;
70
- const model = formatModelLabel(
71
- getChatSettings(s.chatId).model ?? config.model,
72
- );
73
- // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
74
- // it gets the same escaping the title already had.
75
- return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
76
- });
77
- await ctx.reply(
78
- `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
79
- { parse_mode: "HTML" },
80
- );
102
+ await replyActiveChats(ctx, bot, config);
81
103
  return;
82
104
  }
83
105
 
@@ -189,9 +211,9 @@ export async function handleAdminCommand(
189
211
  : "?";
190
212
  return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
191
213
  });
192
- await ctx.reply(
214
+ await replyHtmlChunked(
215
+ ctx,
193
216
  `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
194
- { parse_mode: "HTML" },
195
217
  );
196
218
  return;
197
219
  }
@@ -217,9 +239,10 @@ export async function handleAdminCommand(
217
239
  return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
218
240
  }),
219
241
  );
220
- await ctx.reply(`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"), {
221
- parse_mode: "HTML",
222
- });
242
+ await replyHtmlChunked(
243
+ ctx,
244
+ `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
245
+ );
223
246
  return;
224
247
  }
225
248
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Access gates — the allow-lists are the entire permission model. DMs
3
+ * match `allowedJids` (see identity.ts); groups go through `groupPolicy`
4
+ * and, in mention mode, must address the bot.
5
+ */
6
+
7
+ import type { WAMessage } from "baileys";
8
+ import { logWarn } from "../../util/log.js";
9
+ import { bareId } from "./identity.js";
10
+ import type { WhatsAppRuntime } from "./runtime.js";
11
+
12
+ /**
13
+ * How long a group's membership answer is trusted. Group metadata costs a
14
+ * round-trip, and the `with-allowed-user` policy would otherwise pay it on
15
+ * every inbound message; memberships change on the order of days.
16
+ */
17
+ const GROUP_POLICY_CACHE_MS = 10 * 60_000;
18
+
19
+ /**
20
+ * May the bot act in this group? `allowedGroups` is always honoured;
21
+ * beyond it the policy decides, and "with-allowed-user" asks WhatsApp
22
+ * who is in the group (cached — see GROUP_POLICY_CACHE_MS).
23
+ */
24
+ export async function isGroupAllowed(
25
+ runtime: WhatsAppRuntime,
26
+ jid: string,
27
+ ): Promise<boolean> {
28
+ if (runtime.allowedGroups.has(bareId(jid))) return true;
29
+ if (runtime.settings.groupPolicy === "listed") return false;
30
+ if (runtime.settings.groupPolicy === "all") return true;
31
+
32
+ const cached = runtime.groupAllowCache.get(jid);
33
+ if (cached && Date.now() - cached.at < GROUP_POLICY_CACHE_MS) {
34
+ return cached.allowed;
35
+ }
36
+ let allowed = false;
37
+ try {
38
+ const meta = await runtime.sock!.groupMetadata(jid);
39
+ // A participant is listed by whichever form the group uses, so
40
+ // check every id WhatsApp gives us for them.
41
+ allowed = meta.participants.some((p) =>
42
+ [p.id, p.lid, p.phoneNumber]
43
+ .filter((id): id is string => Boolean(id))
44
+ .some((id) => runtime.allowedDms.has(bareId(id))),
45
+ );
46
+ } catch (err) {
47
+ // A metadata failure must not silently open the group up.
48
+ logWarn(
49
+ "whatsapp",
50
+ `Group policy check failed for ${jid}: ${err instanceof Error ? err.message : err}`,
51
+ );
52
+ }
53
+ runtime.groupAllowCache.set(jid, { allowed, at: Date.now() });
54
+ return allowed;
55
+ }
56
+
57
+ /** Is this group message addressed to us — @mentioned or quoting us? */
58
+ export function isAddressedToSelf(
59
+ selfIds: readonly string[],
60
+ msg: WAMessage,
61
+ ): boolean {
62
+ if (selfIds.length === 0) return false;
63
+ const ctx = msg.message?.extendedTextMessage?.contextInfo;
64
+ const isSelf = (j: string): boolean => selfIds.includes(bareId(j));
65
+ if ((ctx?.mentionedJid ?? []).some(isSelf)) return true;
66
+ return Boolean(ctx?.participant && isSelf(ctx.participant));
67
+ }