talon-agent 3.34.1 → 3.35.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/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +3 -3
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +16 -1
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +13 -3
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/tools/history.ts +10 -1
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- package/src/storage/whatsapp-messages.ts +51 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash commands on WhatsApp — `/model`, `/effort`, `/settings`,
|
|
3
|
+
* `/reset`, `/status`, `/help`.
|
|
4
|
+
*
|
|
5
|
+
* WhatsApp has no inline buttons, so everything Telegram does with a
|
|
6
|
+
* picker is done here with typed arguments: `/model` prints a numbered
|
|
7
|
+
* catalog, `/model 3` (or `/model <id>`, `/model <backend>`) picks from
|
|
8
|
+
* it. The state changes come from frontend/shared/model-commands.ts;
|
|
9
|
+
* this module only parses, authorises, and renders.
|
|
10
|
+
*
|
|
11
|
+
* Replies are written in Markdown — `sendText` translates them into
|
|
12
|
+
* WhatsApp's dialect and splits long listings into bubbles.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { log } from "../../util/log.js";
|
|
16
|
+
import { recordMessageProcessed } from "../../util/watchdog.js";
|
|
17
|
+
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
18
|
+
import { isPulseEnabled } from "../../core/background/pulse.js";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_PULSE_INTERVAL_MS,
|
|
21
|
+
formatBytes,
|
|
22
|
+
formatDuration,
|
|
23
|
+
formatModelLabel,
|
|
24
|
+
formatTokenCount,
|
|
25
|
+
formatUsd,
|
|
26
|
+
} from "../shared/format.js";
|
|
27
|
+
import {
|
|
28
|
+
collectSessionStatus,
|
|
29
|
+
performSessionReset,
|
|
30
|
+
type SessionStatusData,
|
|
31
|
+
} from "../shared/session-status.js";
|
|
32
|
+
import {
|
|
33
|
+
describeChatEffort,
|
|
34
|
+
describeChatModels,
|
|
35
|
+
matchBackendArg,
|
|
36
|
+
resetChatBackend,
|
|
37
|
+
resetChatModel,
|
|
38
|
+
resolveChatBackendPair,
|
|
39
|
+
selectChatModel,
|
|
40
|
+
setChatEffortLevel,
|
|
41
|
+
switchChatBackend,
|
|
42
|
+
type ModelCommandDeps,
|
|
43
|
+
type ModelOverview,
|
|
44
|
+
} from "../shared/model-commands.js";
|
|
45
|
+
import { sendText } from "./actions/shared.js";
|
|
46
|
+
import { identityAllowed, type Identity } from "./identity.js";
|
|
47
|
+
import type { WhatsAppChatInfo } from "./registry.js";
|
|
48
|
+
import type { WhatsAppRuntime } from "./runtime.js";
|
|
49
|
+
|
|
50
|
+
const COMMAND_NAMES = [
|
|
51
|
+
"model",
|
|
52
|
+
"effort",
|
|
53
|
+
"settings",
|
|
54
|
+
"reset",
|
|
55
|
+
"status",
|
|
56
|
+
"help",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
type WhatsAppCommandName = (typeof COMMAND_NAMES)[number];
|
|
60
|
+
|
|
61
|
+
export type WhatsAppCommand = { name: WhatsAppCommandName; arg: string };
|
|
62
|
+
|
|
63
|
+
/** The inbound message fields the command layer needs. */
|
|
64
|
+
export type CommandInbound = {
|
|
65
|
+
chat: WhatsAppChatInfo;
|
|
66
|
+
text: string;
|
|
67
|
+
senderName: string;
|
|
68
|
+
identity: Identity;
|
|
69
|
+
isGroup: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** How many catalog entries `/model` prints before pointing at `/model <id>`. */
|
|
73
|
+
const MAX_LISTED_MODELS = 40;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse a slash command out of message text. Group messages in mention
|
|
77
|
+
* mode arrive as "@<number> /model", so leading @mentions are skipped.
|
|
78
|
+
* Returns null for anything that is not one of ours, including bare
|
|
79
|
+
* text that merely starts with a slash (a path, a fraction).
|
|
80
|
+
*/
|
|
81
|
+
export function parseWhatsAppCommand(text: string): WhatsAppCommand | null {
|
|
82
|
+
const body = text.replace(/^(?:@\S+\s*)+/, "").trim();
|
|
83
|
+
const match = /^\/([a-zA-Z]+)(?:@\S+)?(?:\s+([\s\S]*))?$/.exec(body);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
const name = match[1].toLowerCase();
|
|
86
|
+
if (!(COMMAND_NAMES as readonly string[]).includes(name)) return null;
|
|
87
|
+
return { name: name as WhatsAppCommandName, arg: (match[2] ?? "").trim() };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Commands that change chat state, as opposed to showing it. */
|
|
91
|
+
function isMutating(cmd: WhatsAppCommand): boolean {
|
|
92
|
+
if (cmd.name === "reset") return true;
|
|
93
|
+
return (cmd.name === "model" || cmd.name === "effort") && cmd.arg !== "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* May this sender change the chat's settings? A DM sender already passed
|
|
98
|
+
* the allowlist. In a group, the same allowlist is the admin rule — the
|
|
99
|
+
* people in `allowedJids` are the operator's, everyone else may read
|
|
100
|
+
* settings but not change them. With no allowlist at all there is no
|
|
101
|
+
* admin to distinguish, the way Telegram opens up with no adminUserId.
|
|
102
|
+
*/
|
|
103
|
+
export function canChangeSettings(
|
|
104
|
+
runtime: WhatsAppRuntime,
|
|
105
|
+
inbound: Pick<CommandInbound, "isGroup" | "identity">,
|
|
106
|
+
): boolean {
|
|
107
|
+
if (!inbound.isGroup || runtime.allowedDms.size === 0) return true;
|
|
108
|
+
return identityAllowed(inbound.identity, runtime.allowedDms);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Renderers ───────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function renderModelOverview(view: ModelOverview): string {
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
lines.push(
|
|
116
|
+
view.activeModel
|
|
117
|
+
? `**Model:** \`${view.activeModel}\`${
|
|
118
|
+
view.activeDisplay && view.activeDisplay !== view.activeModel
|
|
119
|
+
? ` (${view.activeDisplay})`
|
|
120
|
+
: ""
|
|
121
|
+
}${view.hasModelOverride ? " — chat override" : ""}`
|
|
122
|
+
: "**Model:** none selected — messages will be refused until you pick one",
|
|
123
|
+
);
|
|
124
|
+
lines.push(
|
|
125
|
+
`**Backend:** ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " — chat override" : ""}`,
|
|
126
|
+
);
|
|
127
|
+
if (view.backends.length > 1) {
|
|
128
|
+
lines.push(
|
|
129
|
+
`Backends: ${view.backends.map((b) => `\`${b.id}\``).join(", ")} — \`/model <backend>\` switches (session restarts).`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (view.choices.length === 0) {
|
|
133
|
+
lines.push(
|
|
134
|
+
"",
|
|
135
|
+
"This backend has no browsable catalog; `/model <id>` sets one directly.",
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
lines.push("", `**Models** (${view.choices.length}):`);
|
|
139
|
+
for (const c of view.choices.slice(0, MAX_LISTED_MODELS)) {
|
|
140
|
+
const marker = c.id === view.activeModel ? " ✓" : "";
|
|
141
|
+
const free = c.free ? " · free" : "";
|
|
142
|
+
lines.push(`${c.index}. ${c.displayName} — \`${c.id}\`${free}${marker}`);
|
|
143
|
+
}
|
|
144
|
+
if (view.choices.length > MAX_LISTED_MODELS) {
|
|
145
|
+
lines.push(
|
|
146
|
+
`… ${view.choices.length - MAX_LISTED_MODELS} more — \`/model <id>\` picks any of them.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
lines.push(
|
|
151
|
+
"",
|
|
152
|
+
"`/model <number>` or `/model <id>` picks; `/model default` clears the chat's pick" +
|
|
153
|
+
(view.hasBackendOverride
|
|
154
|
+
? "; `/model backend default` reverts the backend."
|
|
155
|
+
: "."),
|
|
156
|
+
);
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderSettings(
|
|
161
|
+
view: ModelOverview,
|
|
162
|
+
effort: { current: string; levels: string[] },
|
|
163
|
+
pulseOn: boolean,
|
|
164
|
+
pulseIntervalMs: number | undefined,
|
|
165
|
+
freeOnly: boolean,
|
|
166
|
+
): string {
|
|
167
|
+
const lines = [
|
|
168
|
+
"**Settings**",
|
|
169
|
+
`Model: \`${view.activeModel ?? "none selected"}\`${view.hasModelOverride ? " (chat override)" : ""}`,
|
|
170
|
+
`Backend: ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " (chat override)" : ""}`,
|
|
171
|
+
`Effort: ${effort.current}${effort.levels.length ? ` — levels: ${effort.levels.join(", ")}` : ""}`,
|
|
172
|
+
`Pulse: ${pulseOn ? "on" : "off"} (every ${formatDuration(pulseIntervalMs ?? DEFAULT_PULSE_INTERVAL_MS)})`,
|
|
173
|
+
];
|
|
174
|
+
if (freeOnly) lines.push("Free-only models: on");
|
|
175
|
+
lines.push(
|
|
176
|
+
"",
|
|
177
|
+
"Change with `/model`, `/effort`; `/reset` starts a fresh session.",
|
|
178
|
+
);
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderStatus(s: SessionStatusData): string {
|
|
183
|
+
const used = s.context.known ? formatTokenCount(s.context.used) : "unknown";
|
|
184
|
+
const max = s.context.max > 0 ? formatTokenCount(s.context.max) : "unknown";
|
|
185
|
+
const pct = s.context.known ? `${s.context.pct}%` : "unknown";
|
|
186
|
+
const lines = [
|
|
187
|
+
`**Talon** · \`${formatModelLabel(s.activeModel)}\`${s.backendLabel ? ` · ${s.backendLabel}` : ""} · effort: ${s.effortName}${s.turnInProgress ? " · ⏳ turn running" : ""}`,
|
|
188
|
+
"",
|
|
189
|
+
`**Context** ${used} / ${max} (${pct})${s.context.warn ? " ⚠️ consider /reset" : ""}`,
|
|
190
|
+
`\`${s.context.bar}\``,
|
|
191
|
+
"",
|
|
192
|
+
"**Session stats**",
|
|
193
|
+
`Response: last ${s.lastResponseMs ? formatDuration(s.lastResponseMs) : "—"} · avg ${s.avgResponseMs ? formatDuration(s.avgResponseMs) : "—"} · best ${s.fastestMs ? formatDuration(s.fastestMs) : "—"}`,
|
|
194
|
+
`Turns: ${s.turns}${s.turnsModelLabel ? ` (${formatModelLabel(s.turnsModelLabel)})` : ""}`,
|
|
195
|
+
`Tokens: in ${formatTokenCount(s.inputTokens)} · out ${formatTokenCount(s.outputTokens)}${s.costUsd > 0 ? ` · cost ${formatUsd(s.costUsd)}` : ""}`,
|
|
196
|
+
];
|
|
197
|
+
if (s.cache) {
|
|
198
|
+
lines.push(
|
|
199
|
+
`Cache: ${s.cache.hitPct}% hit · read ${formatTokenCount(s.cache.read)}${s.cache.showsWrite ? ` · write ${formatTokenCount(s.cache.write)}` : ""}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (s.plan) {
|
|
203
|
+
lines.push(
|
|
204
|
+
"",
|
|
205
|
+
`**Plan**${s.plan.plan ? ` ${s.plan.plan}` : ""}${s.plan.ageLabel ? ` (${s.plan.ageLabel})` : ""}`,
|
|
206
|
+
...s.plan.windows.map(
|
|
207
|
+
(w) =>
|
|
208
|
+
`\`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${w.resetLabel ? ` reset ${w.resetLabel}` : ""}`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
lines.push(
|
|
213
|
+
"",
|
|
214
|
+
`**Pulse** ${s.pulseOn ? "on" : "off"}`,
|
|
215
|
+
`**Workspace** ${formatBytes(s.diskBytes)}`,
|
|
216
|
+
`**Session** ${s.sessionName ? `"${s.sessionName}" ` : ""}${s.sessionId ? `\`${s.sessionId.slice(0, 8)}…\`` : "(new)"} · ${s.sessionAge} old`,
|
|
217
|
+
`**Uptime** ${s.uptime} · ${s.activeSessionCount} active session${s.activeSessionCount === 1 ? "" : "s"}`,
|
|
218
|
+
`**Runtime** ${s.runtime} · ${formatBytes(s.rssBytes)} RSS`,
|
|
219
|
+
);
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const HELP_TEXT = [
|
|
224
|
+
"**Commands**",
|
|
225
|
+
"/model — list backends and models; `/model <n|id>` picks, `/model <backend>` switches",
|
|
226
|
+
"/effort — show or set thinking effort: `/effort high`, `/effort adaptive`",
|
|
227
|
+
"/settings — this chat's model, backend, effort and pulse",
|
|
228
|
+
"/status — session info, context usage and stats",
|
|
229
|
+
"/reset — start a fresh session (chat log kept)",
|
|
230
|
+
"/help — this message",
|
|
231
|
+
].join("\n");
|
|
232
|
+
|
|
233
|
+
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
async function runModelCommand(
|
|
236
|
+
chatId: string,
|
|
237
|
+
arg: string,
|
|
238
|
+
deps: ModelCommandDeps,
|
|
239
|
+
): Promise<string> {
|
|
240
|
+
if (!arg) return renderModelOverview(await describeChatModels(chatId, deps));
|
|
241
|
+
const lower = arg.toLowerCase();
|
|
242
|
+
// `/reset` keeps history on WhatsApp because the local store is the only
|
|
243
|
+
// chat record; a backend switch keeps it for the same reason.
|
|
244
|
+
if (lower === "backend default" || lower === "backend reset") {
|
|
245
|
+
return (await resetChatBackend(chatId, deps, { keepHistory: true })).text;
|
|
246
|
+
}
|
|
247
|
+
if (lower === "reset" || lower === "default") {
|
|
248
|
+
return (await resetChatModel(chatId, deps)).text;
|
|
249
|
+
}
|
|
250
|
+
const backend = matchBackendArg(arg, deps.config);
|
|
251
|
+
if (backend) {
|
|
252
|
+
return (
|
|
253
|
+
await switchChatBackend(chatId, backend, deps, { keepHistory: true })
|
|
254
|
+
).text;
|
|
255
|
+
}
|
|
256
|
+
return (await selectChatModel(chatId, arg, deps)).text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function runEffortCommand(
|
|
260
|
+
chatId: string,
|
|
261
|
+
arg: string,
|
|
262
|
+
deps: ModelCommandDeps,
|
|
263
|
+
): Promise<string> {
|
|
264
|
+
if (arg) return (await setChatEffortLevel(chatId, arg, deps)).text;
|
|
265
|
+
const effort = await describeChatEffort(chatId, deps);
|
|
266
|
+
if (effort.levels.length === 0) {
|
|
267
|
+
return `No reasoning levels available for ${effort.activeModel ?? "the active model"} on backend ${effort.backendId}.`;
|
|
268
|
+
}
|
|
269
|
+
return `**Effort:** ${effort.current}\nLevels: ${effort.levels.join(", ")}, or adaptive — \`/effort <level>\` sets one.`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function runSettingsCommand(
|
|
273
|
+
chatId: string,
|
|
274
|
+
deps: ModelCommandDeps,
|
|
275
|
+
): Promise<string> {
|
|
276
|
+
const [view, effort] = await Promise.all([
|
|
277
|
+
describeChatModels(chatId, deps),
|
|
278
|
+
describeChatEffort(chatId, deps),
|
|
279
|
+
]);
|
|
280
|
+
const sets = getChatSettings(chatId);
|
|
281
|
+
return renderSettings(
|
|
282
|
+
view,
|
|
283
|
+
effort,
|
|
284
|
+
isPulseEnabled(chatId),
|
|
285
|
+
sets.pulseIntervalMs,
|
|
286
|
+
sets.freeOnly === true,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function runStatusCommand(
|
|
291
|
+
chatId: string,
|
|
292
|
+
deps: ModelCommandDeps,
|
|
293
|
+
): Promise<string> {
|
|
294
|
+
const { backend, backendId } = resolveChatBackendPair(chatId, deps);
|
|
295
|
+
return renderStatus(
|
|
296
|
+
await collectSessionStatus(chatId, deps.config, backend, backendId),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function runResetCommand(
|
|
301
|
+
chatId: string,
|
|
302
|
+
senderName: string,
|
|
303
|
+
deps: ModelCommandDeps,
|
|
304
|
+
): Promise<string> {
|
|
305
|
+
// The local history store is WhatsApp's only chat record — a reset
|
|
306
|
+
// clears the model's session, not the conversation log.
|
|
307
|
+
await performSessionReset(
|
|
308
|
+
chatId,
|
|
309
|
+
resolveChatBackendPair(chatId, deps).backend,
|
|
310
|
+
{
|
|
311
|
+
keepHistory: true,
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
log("whatsapp", `Session reset by ${senderName}`);
|
|
315
|
+
return "Session cleared.";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The reply text for one parsed command. */
|
|
319
|
+
export async function executeWhatsAppCommand(
|
|
320
|
+
runtime: WhatsAppRuntime,
|
|
321
|
+
cmd: WhatsAppCommand,
|
|
322
|
+
inbound: CommandInbound,
|
|
323
|
+
): Promise<string> {
|
|
324
|
+
const chatId = inbound.chat.chatId;
|
|
325
|
+
const deps: ModelCommandDeps = {
|
|
326
|
+
config: runtime.config,
|
|
327
|
+
gateway: runtime.gateway,
|
|
328
|
+
};
|
|
329
|
+
if (isMutating(cmd) && !canChangeSettings(runtime, inbound)) {
|
|
330
|
+
return "Only allowlisted users can change settings in a group.";
|
|
331
|
+
}
|
|
332
|
+
switch (cmd.name) {
|
|
333
|
+
case "model":
|
|
334
|
+
return runModelCommand(chatId, cmd.arg, deps);
|
|
335
|
+
case "effort":
|
|
336
|
+
return runEffortCommand(chatId, cmd.arg, deps);
|
|
337
|
+
case "settings":
|
|
338
|
+
return runSettingsCommand(chatId, deps);
|
|
339
|
+
case "status":
|
|
340
|
+
return runStatusCommand(chatId, deps);
|
|
341
|
+
case "reset":
|
|
342
|
+
return runResetCommand(chatId, inbound.senderName, deps);
|
|
343
|
+
case "help":
|
|
344
|
+
return HELP_TEXT;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handle a slash command if the message is one. True when it was — the
|
|
350
|
+
* caller then skips the agent turn. Delivery failures are swallowed:
|
|
351
|
+
* the state change already happened and is visible on the next
|
|
352
|
+
* `/settings`; a dead socket is the connection loop's problem.
|
|
353
|
+
*/
|
|
354
|
+
export async function handleWhatsAppCommand(
|
|
355
|
+
runtime: WhatsAppRuntime,
|
|
356
|
+
inbound: CommandInbound,
|
|
357
|
+
): Promise<boolean> {
|
|
358
|
+
const cmd = parseWhatsAppCommand(inbound.text);
|
|
359
|
+
if (!cmd) return false;
|
|
360
|
+
const reply = await executeWhatsAppCommand(runtime, cmd, inbound);
|
|
361
|
+
const sock = runtime.sock;
|
|
362
|
+
if (sock) {
|
|
363
|
+
await sendText(
|
|
364
|
+
{ sock, gateway: runtime.gateway },
|
|
365
|
+
inbound.chat,
|
|
366
|
+
reply,
|
|
367
|
+
).catch(() => {});
|
|
368
|
+
}
|
|
369
|
+
recordMessageProcessed();
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One inbound message, from `messages.upsert` to the model's turn:
|
|
3
3
|
* access gates → media saved to the workspace → history recorded →
|
|
4
|
-
* slash commands → catch-up policy → `execute()`.
|
|
4
|
+
* slash commands (commands.ts) → catch-up policy → `execute()`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { isJidGroup, type WAMessage } from "baileys";
|
|
@@ -9,8 +9,6 @@ import type { AgentEvent } from "../../core/agent-runtime/events.js";
|
|
|
9
9
|
import { log, logError } from "../../util/log.js";
|
|
10
10
|
import { execute } from "../../core/engine/dispatcher.js";
|
|
11
11
|
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
12
|
-
import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
|
|
13
|
-
import { performSessionReset } from "../shared/session-status.js";
|
|
14
12
|
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
15
13
|
import { pushMessage } from "../../storage/history.js";
|
|
16
14
|
import {
|
|
@@ -18,6 +16,7 @@ import {
|
|
|
18
16
|
recordMessageReceived,
|
|
19
17
|
} from "../../util/watchdog.js";
|
|
20
18
|
import { isAddressedToSelf, isGroupAllowed } from "./access.js";
|
|
19
|
+
import { handleWhatsAppCommand } from "./commands.js";
|
|
21
20
|
import { sendText } from "./actions/shared.js";
|
|
22
21
|
import {
|
|
23
22
|
bareId,
|
|
@@ -92,8 +91,13 @@ async function admitInbound(
|
|
|
92
91
|
if (
|
|
93
92
|
runtime.settings.respondMode === "mention" &&
|
|
94
93
|
!isAddressedToSelf(runtime.selfIds, msg)
|
|
95
|
-
)
|
|
94
|
+
) {
|
|
95
|
+
// Not for us to answer, but still part of the conversation: the
|
|
96
|
+
// history store is the model's only record of the group, and a
|
|
97
|
+
// turn that reads it must see the messages around the mention.
|
|
98
|
+
await recordPassively(runtime, msg, { jid, isGroup, identity });
|
|
96
99
|
return null;
|
|
100
|
+
}
|
|
97
101
|
} else if (!identityAllowed(identity, runtime.allowedDms)) {
|
|
98
102
|
log(
|
|
99
103
|
"whatsapp",
|
|
@@ -104,6 +108,21 @@ async function admitInbound(
|
|
|
104
108
|
return { jid, isGroup, identity };
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/** Record a message that gets no turn — group chatter the bot only reads. */
|
|
112
|
+
async function recordPassively(
|
|
113
|
+
runtime: WhatsAppRuntime,
|
|
114
|
+
msg: WAMessage,
|
|
115
|
+
admitted: AdmittedMessage,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const recorded = await recordInbound(runtime, msg, admitted);
|
|
118
|
+
if (!recorded) return;
|
|
119
|
+
log(
|
|
120
|
+
"whatsapp",
|
|
121
|
+
`[${recorded.chat.chatId}] Recorded group message from ${recorded.senderName} (history only)`,
|
|
122
|
+
);
|
|
123
|
+
recordMessageProcessed();
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
async function recordInbound(
|
|
108
127
|
runtime: WhatsAppRuntime,
|
|
109
128
|
msg: WAMessage,
|
|
@@ -159,47 +178,6 @@ async function recordInbound(
|
|
|
159
178
|
return { ...admitted, chat, msgId, text, media, senderName, platformTs };
|
|
160
179
|
}
|
|
161
180
|
|
|
162
|
-
/** `/reset` and `/help`. True when the message was a command. */
|
|
163
|
-
async function handleSlashCommand(
|
|
164
|
-
runtime: WhatsAppRuntime,
|
|
165
|
-
inbound: RecordedMessage,
|
|
166
|
-
): Promise<boolean> {
|
|
167
|
-
const { chat, senderName } = inbound;
|
|
168
|
-
const { gateway } = runtime;
|
|
169
|
-
const trimmed = inbound.text.toLowerCase();
|
|
170
|
-
if (trimmed === "/reset") {
|
|
171
|
-
await performSessionReset(
|
|
172
|
-
chat.chatId,
|
|
173
|
-
resolveChatBackend(chat.chatId, gateway.backend),
|
|
174
|
-
// The local history store is WhatsApp's only chat record — a
|
|
175
|
-
// reset clears the model's session, not the conversation log.
|
|
176
|
-
{ keepHistory: true },
|
|
177
|
-
);
|
|
178
|
-
log("whatsapp", `Session reset by ${senderName}`);
|
|
179
|
-
const sock = runtime.sock;
|
|
180
|
-
if (sock) {
|
|
181
|
-
await sendText({ sock, gateway }, chat, "Session cleared.").catch(
|
|
182
|
-
() => {},
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
recordMessageProcessed();
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
if (trimmed === "/help") {
|
|
189
|
-
const sock = runtime.sock;
|
|
190
|
-
if (sock) {
|
|
191
|
-
await sendText(
|
|
192
|
-
{ sock, gateway },
|
|
193
|
-
chat,
|
|
194
|
-
"*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
|
|
195
|
-
).catch(() => {});
|
|
196
|
-
}
|
|
197
|
-
recordMessageProcessed();
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
181
|
/**
|
|
204
182
|
* Catch-up messages (queued while the daemon was down) get a reply
|
|
205
183
|
* turn only while fresh; stale ones are already recorded and the next
|
|
@@ -321,7 +299,7 @@ export async function handleInbound(
|
|
|
321
299
|
if (!admitted) return;
|
|
322
300
|
const inbound = await recordInbound(runtime, msg, admitted);
|
|
323
301
|
if (!inbound) return;
|
|
324
|
-
if (await
|
|
302
|
+
if (await handleWhatsAppCommand(runtime, inbound)) return;
|
|
325
303
|
if (options.catchUp && !catchUpDeservesReply(inbound)) return;
|
|
326
304
|
await runInboundTurn(runtime, inbound);
|
|
327
305
|
}
|
|
@@ -27,6 +27,10 @@ import { maxMsgIdForChatPrefix } from "../../storage/history.js";
|
|
|
27
27
|
import { createWhatsAppActionHandler } from "./actions/index.js";
|
|
28
28
|
import { sendText, setWhatsAppBotName } from "./actions/shared.js";
|
|
29
29
|
import { seedMessageStore } from "./message-store.js";
|
|
30
|
+
import {
|
|
31
|
+
maxWhatsAppMsgId,
|
|
32
|
+
pruneWhatsAppMessages,
|
|
33
|
+
} from "../../storage/whatsapp-messages.js";
|
|
30
34
|
import { flushAuthWrites } from "./auth-state.js";
|
|
31
35
|
import { registerPairingProvider } from "../../core/pairing-broker.js";
|
|
32
36
|
import { beginPairingAttempt } from "./pairing-service.js";
|
|
@@ -112,7 +116,14 @@ export function createWhatsAppFrontend(
|
|
|
112
116
|
// but history persists — seed it past what the table already holds
|
|
113
117
|
// so post-restart messages don't re-issue ids INSERT OR IGNORE then
|
|
114
118
|
// silently drops (chat ids all start with "wa_").
|
|
115
|
-
seedMessageStore(
|
|
119
|
+
seedMessageStore(
|
|
120
|
+
Math.max(maxMsgIdForChatPrefix("wa_") ?? 0, maxWhatsAppMsgId() ?? 0) +
|
|
121
|
+
1,
|
|
122
|
+
);
|
|
123
|
+
const pruned = pruneWhatsAppMessages();
|
|
124
|
+
if (pruned > 0) {
|
|
125
|
+
log("whatsapp", `Pruned ${pruned} expired message key(s)`);
|
|
126
|
+
}
|
|
116
127
|
gateway.registerFrontendHandler(
|
|
117
128
|
"whatsapp",
|
|
118
129
|
createWhatsAppActionHandler(() => runtime.sock, gateway),
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { downloadMediaMessage, type WAMessage } from "baileys";
|
|
14
|
+
import { makeWaLogger } from "./wa-logger.js";
|
|
14
15
|
import { addMedia } from "../../storage/media-index.js";
|
|
15
16
|
import { logWarn } from "../../util/log.js";
|
|
16
17
|
import { dirs } from "../../util/paths.js";
|
|
@@ -30,6 +31,16 @@ export type SavedMedia = {
|
|
|
30
31
|
caption?: string;
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* How to recover media WhatsApp's CDN no longer serves. Media URLs expire
|
|
36
|
+
* (days to weeks); `reuploadRequest` — the socket's `updateMediaMessage`
|
|
37
|
+
* — asks the sender's device to upload the file again and returns the
|
|
38
|
+
* refreshed message, which Baileys then downloads.
|
|
39
|
+
*/
|
|
40
|
+
export type MediaRetrieval = {
|
|
41
|
+
reuploadRequest: (message: WAMessage) => Promise<WAMessage>;
|
|
42
|
+
};
|
|
43
|
+
|
|
33
44
|
/** The media kind carried by a message, if any. */
|
|
34
45
|
function mediaKindOf(
|
|
35
46
|
message: WAMessage,
|
|
@@ -50,13 +61,21 @@ export async function saveInboundMedia(
|
|
|
50
61
|
chatId: string,
|
|
51
62
|
msgId: number,
|
|
52
63
|
senderName: string,
|
|
64
|
+
retrieval?: MediaRetrieval,
|
|
53
65
|
): Promise<SavedMedia | undefined> {
|
|
54
66
|
const kind = mediaKindOf(message);
|
|
55
67
|
if (!kind) return undefined;
|
|
56
68
|
const [field, type, defaultExt] = kind;
|
|
57
69
|
|
|
58
70
|
try {
|
|
59
|
-
const buffer = await downloadMediaMessage(
|
|
71
|
+
const buffer = await downloadMediaMessage(
|
|
72
|
+
message,
|
|
73
|
+
"buffer",
|
|
74
|
+
{},
|
|
75
|
+
retrieval
|
|
76
|
+
? { reuploadRequest: retrieval.reuploadRequest, logger: makeWaLogger() }
|
|
77
|
+
: undefined,
|
|
78
|
+
);
|
|
60
79
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return undefined;
|
|
61
80
|
|
|
62
81
|
const content = message.message?.[field] as
|