talon-agent 3.33.4 → 3.34.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/package.json +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Baileys socket lifecycle: one socket per `connectOnce`, the
|
|
3
|
+
* reconnect/backoff loop around it, and the parked state a logged-out or
|
|
4
|
+
* never-paired account waits in until `/whatsapp pair` links it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { rmSync } from "node:fs";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { resolve as resolvePath } from "node:path";
|
|
10
|
+
import makeWASocket, { type AuthenticationState, type WASocket } from "baileys";
|
|
11
|
+
import qrcode from "qrcode-terminal";
|
|
12
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
13
|
+
import { dirs } from "../../util/paths.js";
|
|
14
|
+
import { notifyAdmin } from "../../core/notify.js";
|
|
15
|
+
import { useAtomicAuthState } from "./auth-state.js";
|
|
16
|
+
import { bareId } from "./identity.js";
|
|
17
|
+
import { handleInbound } from "./inbound.js";
|
|
18
|
+
import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
19
|
+
import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
|
|
20
|
+
import {
|
|
21
|
+
RECONNECT_BASE_MS,
|
|
22
|
+
RECONNECT_MAX_MS,
|
|
23
|
+
type WhatsAppRuntime,
|
|
24
|
+
} from "./runtime.js";
|
|
25
|
+
import { makeWaLogger } from "./wa-logger.js";
|
|
26
|
+
|
|
27
|
+
/** What the connection loop should do after a socket ends. */
|
|
28
|
+
type ConnectOutcome = "reconnect" | "logged-out" | "unpaired" | "stop";
|
|
29
|
+
|
|
30
|
+
function bindInbound(runtime: WhatsAppRuntime, socket: WASocket): void {
|
|
31
|
+
socket.ev.on("messages.upsert", ({ messages, type }) => {
|
|
32
|
+
// "notify" is a live message. "append" is everything delivered out
|
|
33
|
+
// of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
|
|
34
|
+
// (Baileys marks offline-queued nodes as append), but also our own
|
|
35
|
+
// sends echoing back and newsletter posts, which handleInbound's
|
|
36
|
+
// fromMe/allowlist gates drop. Dropping append wholesale meant any
|
|
37
|
+
// message sent during a restart simply vanished: never recorded,
|
|
38
|
+
// never answered. Appends are processed as catch-up: always
|
|
39
|
+
// recorded, replied to only while fresh.
|
|
40
|
+
if (type !== "notify" && type !== "append") return;
|
|
41
|
+
const catchUp = type === "append";
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
void handleInbound(runtime, msg, { catchUp }).catch((err) => {
|
|
44
|
+
logError(
|
|
45
|
+
"whatsapp",
|
|
46
|
+
`inbound handler failed: ${err instanceof Error ? err.message : err}`,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onQr(runtime: WhatsAppRuntime, qr: string): void {
|
|
54
|
+
// The frontend NEVER requests pairing codes on its own — that
|
|
55
|
+
// loop once burned ~30 codes in 80 minutes and rate-limited
|
|
56
|
+
// the account. Pairing is on demand via /whatsapp pair
|
|
57
|
+
// (core/pairing-broker.ts). The terminal QR stays for
|
|
58
|
+
// foreground first-run setups without a Telegram admin.
|
|
59
|
+
log(
|
|
60
|
+
"whatsapp",
|
|
61
|
+
"Not linked — pair on demand with /whatsapp pair (Telegram), " +
|
|
62
|
+
"or scan below: WhatsApp → Linked devices → Link a device",
|
|
63
|
+
);
|
|
64
|
+
qrcode.generate(qr, { small: true });
|
|
65
|
+
if (!runtime.unpairedNotified) {
|
|
66
|
+
runtime.unpairedNotified = true;
|
|
67
|
+
void notifyAdmin(
|
|
68
|
+
"📱 WhatsApp is not linked. Send /whatsapp pair when you're " +
|
|
69
|
+
"ready and I'll reply with a QR to scan.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onOpen(runtime: WhatsAppRuntime, socket: WASocket): void {
|
|
75
|
+
// Both forms: a group can @-mention us by either.
|
|
76
|
+
runtime.selfIds = [socket.user?.id, socket.user?.lid]
|
|
77
|
+
.filter((id): id is string => Boolean(id))
|
|
78
|
+
.map(bareId);
|
|
79
|
+
runtime.reconnectDelay = RECONNECT_BASE_MS;
|
|
80
|
+
if (runtime.unpairedNotified) {
|
|
81
|
+
void notifyAdmin("✅ WhatsApp linked and connected.");
|
|
82
|
+
}
|
|
83
|
+
runtime.unpairedNotified = false;
|
|
84
|
+
log(
|
|
85
|
+
"whatsapp",
|
|
86
|
+
`Connected as ${socket.user?.name ?? "?"} (${runtime.selfIds.join("/") || "?"})`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onClose(
|
|
91
|
+
runtime: WhatsAppRuntime,
|
|
92
|
+
state: AuthenticationState,
|
|
93
|
+
code: number | undefined,
|
|
94
|
+
): ConnectOutcome {
|
|
95
|
+
if (runtime.stopping) return "stop";
|
|
96
|
+
const disposition = classifyClose(code, state.creds.registered);
|
|
97
|
+
switch (disposition.kind) {
|
|
98
|
+
case "pairing-accepted":
|
|
99
|
+
// 515 right after a pairing code/QR is SUCCESS, not an
|
|
100
|
+
// error: WhatsApp requires one reconnect with the same
|
|
101
|
+
// credentials to complete the login.
|
|
102
|
+
log(
|
|
103
|
+
"whatsapp",
|
|
104
|
+
"Pairing accepted (515) — reconnecting to complete login",
|
|
105
|
+
);
|
|
106
|
+
runtime.reconnectDelay = RECONNECT_BASE_MS;
|
|
107
|
+
return "reconnect";
|
|
108
|
+
case "replaced":
|
|
109
|
+
// Another socket owns this session (440). Fighting it with
|
|
110
|
+
// an instant reconnect just steals the session back and
|
|
111
|
+
// forth; sit out a full minute instead.
|
|
112
|
+
logWarn(
|
|
113
|
+
"whatsapp",
|
|
114
|
+
"Connection replaced by another client (440) — backing off",
|
|
115
|
+
);
|
|
116
|
+
runtime.reconnectDelay = REPLACED_BACKOFF_MS;
|
|
117
|
+
return "reconnect";
|
|
118
|
+
case "logged-out":
|
|
119
|
+
return "logged-out";
|
|
120
|
+
default:
|
|
121
|
+
// A socket that died without ever completing a login is a
|
|
122
|
+
// pairing window that expired — reconnecting would just open QR
|
|
123
|
+
// session after QR session against WhatsApp's servers.
|
|
124
|
+
// Park instead; /whatsapp pair opens the next one.
|
|
125
|
+
//
|
|
126
|
+
// Test with isPaired, not creds.registered: a QR-linked session
|
|
127
|
+
// never sets that flag, so the flag alone parks a healthy
|
|
128
|
+
// session on its first ordinary disconnect.
|
|
129
|
+
if (!isPaired(state.creds)) return "unpaired";
|
|
130
|
+
log("whatsapp", `Connection closed (code ${code ?? "?"}) — reconnecting`);
|
|
131
|
+
return "reconnect";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
136
|
+
async function connectOnce(runtime: WhatsAppRuntime): Promise<ConnectOutcome> {
|
|
137
|
+
// Atomic replacement for Baileys' useMultiFileAuthState — same disk
|
|
138
|
+
// format, torn-write-proof (see auth-state.ts for why that matters).
|
|
139
|
+
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
140
|
+
const socket = makeWASocket({
|
|
141
|
+
auth: state,
|
|
142
|
+
logger: makeWaLogger(),
|
|
143
|
+
markOnlineOnConnect: false,
|
|
144
|
+
// The account is a bot: announcing "online" would suppress the
|
|
145
|
+
// phone's own notifications for the human who owns the number.
|
|
146
|
+
//
|
|
147
|
+
// 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
|
|
148
|
+
// a ~2½-minute socket lifetime in pairing mode, shorter than it
|
|
149
|
+
// takes a human to pick up their phone and type the code.
|
|
150
|
+
qrTimeout: 120_000,
|
|
151
|
+
// OpenClaw's production timings: the Baileys 20s connect timeout
|
|
152
|
+
// is tight on a loaded box, and a slightly faster keepalive spots
|
|
153
|
+
// a dead transport sooner.
|
|
154
|
+
connectTimeoutMs: 60_000,
|
|
155
|
+
keepAliveIntervalMs: 25_000,
|
|
156
|
+
});
|
|
157
|
+
runtime.sock = socket;
|
|
158
|
+
socket.ev.on("creds.update", saveCreds);
|
|
159
|
+
bindInbound(runtime, socket);
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
socket.ev.on("connection.update", (update) => {
|
|
163
|
+
const { connection, lastDisconnect, qr } = update;
|
|
164
|
+
if (qr && !state.creds.registered) onQr(runtime, qr);
|
|
165
|
+
if (connection === "open") onOpen(runtime, socket);
|
|
166
|
+
if (connection === "close") {
|
|
167
|
+
const code = (
|
|
168
|
+
lastDisconnect?.error as
|
|
169
|
+
{ output?: { statusCode?: number } } | undefined
|
|
170
|
+
)?.output?.statusCode;
|
|
171
|
+
resolve(onClose(runtime, state, code));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wait until a manual pairing completes (event from pairing-lock) or
|
|
179
|
+
* registered creds appear on disk, polling slowly. Resolves promptly
|
|
180
|
+
* when the frontend is stopping.
|
|
181
|
+
*/
|
|
182
|
+
async function parkUntilPaired(runtime: WhatsAppRuntime): Promise<void> {
|
|
183
|
+
while (!runtime.stopping) {
|
|
184
|
+
if (!isManualPairingActive()) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = await readFile(
|
|
187
|
+
resolvePath(dirs.whatsappAuth, "creds.json"),
|
|
188
|
+
"utf-8",
|
|
189
|
+
);
|
|
190
|
+
// Same trap as above: waiting on `registered` alone means a QR
|
|
191
|
+
// re-link can never end the park, because that flag stays false.
|
|
192
|
+
if (
|
|
193
|
+
isPaired(
|
|
194
|
+
JSON.parse(raw) as {
|
|
195
|
+
registered?: boolean;
|
|
196
|
+
me?: { id?: string } | null;
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return;
|
|
201
|
+
} catch {
|
|
202
|
+
/* no creds yet — stay parked */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const paired = await new Promise<boolean>((r) => {
|
|
206
|
+
const off = onPairingComplete(() => {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
off();
|
|
209
|
+
r(true);
|
|
210
|
+
});
|
|
211
|
+
const timer = setTimeout(() => {
|
|
212
|
+
off();
|
|
213
|
+
r(false);
|
|
214
|
+
}, 30_000);
|
|
215
|
+
timer.unref?.();
|
|
216
|
+
});
|
|
217
|
+
if (paired) return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The frontend's whole `start()`: connect, reconnect, park, until stopped. */
|
|
222
|
+
export async function runConnectionLoop(
|
|
223
|
+
runtime: WhatsAppRuntime,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
|
|
226
|
+
while (!runtime.stopping) {
|
|
227
|
+
// A manual pairing attempt owns the auth dir: two sockets on one
|
|
228
|
+
// keypair corrupt it and burn rate-limited pairing attempts.
|
|
229
|
+
if (isManualPairingActive()) {
|
|
230
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
let outcome: ConnectOutcome;
|
|
234
|
+
try {
|
|
235
|
+
outcome = await connectOnce(runtime);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
logError(
|
|
238
|
+
"whatsapp",
|
|
239
|
+
`Socket error: ${err instanceof Error ? err.message : err}`,
|
|
240
|
+
);
|
|
241
|
+
outcome = "reconnect";
|
|
242
|
+
}
|
|
243
|
+
runtime.sock = null;
|
|
244
|
+
if (outcome === "stop" || runtime.stopping) break;
|
|
245
|
+
if (outcome === "logged-out") {
|
|
246
|
+
// These credentials are dead — WhatsApp unlinked the device.
|
|
247
|
+
// Wipe them and PARK. Re-pairing needs a human holding the
|
|
248
|
+
// phone, so it happens strictly on demand (Telegram's
|
|
249
|
+
// `/whatsapp pair`) — every automatic retry policy tried here,
|
|
250
|
+
// including a 30-minute backoff ladder, still burned codes
|
|
251
|
+
// against WhatsApp's rate limit until scans failed with
|
|
252
|
+
// "couldn't connect device" for everyone.
|
|
253
|
+
logWarn(
|
|
254
|
+
"whatsapp",
|
|
255
|
+
"Logged out by WhatsApp — parked until /whatsapp pair re-links",
|
|
256
|
+
);
|
|
257
|
+
void notifyAdmin(
|
|
258
|
+
"⚠️ WhatsApp unlinked this device. When you're ready to " +
|
|
259
|
+
"re-link, send /whatsapp pair and scan the QR I reply with. " +
|
|
260
|
+
"Nothing is retried until then.",
|
|
261
|
+
);
|
|
262
|
+
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
263
|
+
await parkUntilPaired(runtime);
|
|
264
|
+
runtime.reconnectDelay = RECONNECT_BASE_MS;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (outcome === "unpaired") {
|
|
268
|
+
log("whatsapp", "Not paired — parked until /whatsapp pair links");
|
|
269
|
+
await parkUntilPaired(runtime);
|
|
270
|
+
runtime.reconnectDelay = RECONNECT_BASE_MS;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
await new Promise((r) => setTimeout(r, runtime.reconnectDelay));
|
|
274
|
+
runtime.reconnectDelay = Math.min(
|
|
275
|
+
runtime.reconnectDelay * 2,
|
|
276
|
+
RECONNECT_MAX_MS,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
log("whatsapp", "WhatsApp connection loop ended");
|
|
280
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One inbound message, from `messages.upsert` to the model's turn:
|
|
3
|
+
* access gates → media saved to the workspace → history recorded →
|
|
4
|
+
* slash commands → catch-up policy → `execute()`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isJidGroup, type WAMessage } from "baileys";
|
|
8
|
+
import type { AgentEvent } from "../../core/agent-runtime/events.js";
|
|
9
|
+
import { log, logError } from "../../util/log.js";
|
|
10
|
+
import { execute } from "../../core/engine/dispatcher.js";
|
|
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
|
+
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
15
|
+
import { pushMessage } from "../../storage/history.js";
|
|
16
|
+
import {
|
|
17
|
+
recordMessageProcessed,
|
|
18
|
+
recordMessageReceived,
|
|
19
|
+
} from "../../util/watchdog.js";
|
|
20
|
+
import { isAddressedToSelf, isGroupAllowed } from "./access.js";
|
|
21
|
+
import { sendText } from "./actions/shared.js";
|
|
22
|
+
import {
|
|
23
|
+
bareId,
|
|
24
|
+
canonicalId,
|
|
25
|
+
identityAllowed,
|
|
26
|
+
resolveIdentity,
|
|
27
|
+
type Identity,
|
|
28
|
+
} from "./identity.js";
|
|
29
|
+
import { saveInboundMedia, type SavedMedia } from "./media-store.js";
|
|
30
|
+
import { lookupByWaId, rememberMessage } from "./message-store.js";
|
|
31
|
+
import { registerWhatsAppChat, type WhatsAppChatInfo } from "./registry.js";
|
|
32
|
+
import type { WhatsAppRuntime } from "./runtime.js";
|
|
33
|
+
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
34
|
+
|
|
35
|
+
export type InboundOptions = { catchUp?: boolean };
|
|
36
|
+
|
|
37
|
+
/** A message that passed the access gates. */
|
|
38
|
+
type AdmittedMessage = {
|
|
39
|
+
jid: string;
|
|
40
|
+
isGroup: boolean;
|
|
41
|
+
identity: Identity;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** An admitted message with something to say, recorded in history. */
|
|
45
|
+
type RecordedMessage = AdmittedMessage & {
|
|
46
|
+
chat: WhatsAppChatInfo;
|
|
47
|
+
msgId: number;
|
|
48
|
+
text: string;
|
|
49
|
+
media: SavedMedia | undefined;
|
|
50
|
+
senderName: string;
|
|
51
|
+
platformTs: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Plain text of an inbound message, across the wrappers WhatsApp uses. */
|
|
55
|
+
function extractText(msg: WAMessage): string {
|
|
56
|
+
const m = msg.message;
|
|
57
|
+
if (!m) return "";
|
|
58
|
+
return (
|
|
59
|
+
m.conversation ??
|
|
60
|
+
m.extendedTextMessage?.text ??
|
|
61
|
+
m.imageMessage?.caption ??
|
|
62
|
+
m.videoMessage?.caption ??
|
|
63
|
+
m.documentMessage?.caption ??
|
|
64
|
+
m.documentWithCaptionMessage?.message?.documentMessage?.caption ??
|
|
65
|
+
""
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function admitInbound(
|
|
70
|
+
runtime: WhatsAppRuntime,
|
|
71
|
+
msg: WAMessage,
|
|
72
|
+
): Promise<AdmittedMessage | null> {
|
|
73
|
+
const jid = msg.key.remoteJid;
|
|
74
|
+
// `fromMe` covers our own sends echoing back; status@broadcast is the
|
|
75
|
+
// Stories feed, which is not a conversation.
|
|
76
|
+
if (!jid || jid === "status@broadcast" || msg.key.fromMe) return null;
|
|
77
|
+
|
|
78
|
+
const isGroup = Boolean(isJidGroup(jid));
|
|
79
|
+
// The sender may be addressed by phone number or by LID depending on
|
|
80
|
+
// their privacy settings; resolve both before matching an allowlist
|
|
81
|
+
// that is written in phone numbers.
|
|
82
|
+
const senderJid = msg.key.participant || jid;
|
|
83
|
+
const identity = await resolveIdentity(
|
|
84
|
+
runtime.sock,
|
|
85
|
+
senderJid,
|
|
86
|
+
msg.key.participantAlt ?? msg.key.remoteJidAlt,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── Access gates: the allowlists are the entire permission model ──
|
|
90
|
+
if (isGroup) {
|
|
91
|
+
if (!(await isGroupAllowed(runtime, jid))) return null;
|
|
92
|
+
if (
|
|
93
|
+
runtime.settings.respondMode === "mention" &&
|
|
94
|
+
!isAddressedToSelf(runtime.selfIds, msg)
|
|
95
|
+
)
|
|
96
|
+
return null;
|
|
97
|
+
} else if (!identityAllowed(identity, runtime.allowedDms)) {
|
|
98
|
+
log(
|
|
99
|
+
"whatsapp",
|
|
100
|
+
`Ignoring DM from unlisted ${identity.ids.join("/") || bareId(jid)}`,
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return { jid, isGroup, identity };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function recordInbound(
|
|
108
|
+
runtime: WhatsAppRuntime,
|
|
109
|
+
msg: WAMessage,
|
|
110
|
+
admitted: AdmittedMessage,
|
|
111
|
+
): Promise<RecordedMessage | null> {
|
|
112
|
+
const { jid, isGroup, identity } = admitted;
|
|
113
|
+
const text = extractText(msg).trim();
|
|
114
|
+
// Group chats key on the group JID; DMs key on the person, so the
|
|
115
|
+
// thread survives WhatsApp switching addressing form.
|
|
116
|
+
const chat = registerWhatsAppChat(
|
|
117
|
+
jid,
|
|
118
|
+
undefined,
|
|
119
|
+
isGroup ? undefined : canonicalId(identity),
|
|
120
|
+
);
|
|
121
|
+
const senderName = msg.pushName || canonicalId(identity) || "user";
|
|
122
|
+
const msgId = rememberMessage({
|
|
123
|
+
key: msg.key,
|
|
124
|
+
chatId: chat.chatId,
|
|
125
|
+
message: msg,
|
|
126
|
+
text,
|
|
127
|
+
senderName,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Media is saved before the turn so the model can open the file by
|
|
131
|
+
// path in the same turn it's told about it.
|
|
132
|
+
const media = await saveInboundMedia(msg, chat.chatId, msgId, senderName);
|
|
133
|
+
if (!text && !media) return null; // reaction, receipt, or an unsupported type
|
|
134
|
+
|
|
135
|
+
recordMessageReceived();
|
|
136
|
+
if (runtime.settings.sendReadReceipts) {
|
|
137
|
+
runtime.sock?.readMessages([msg.key]).catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Recorded for read_chat_history / search_chat_history, which the core
|
|
141
|
+
// serves from this store for frontends without a platform history API.
|
|
142
|
+
const replyToWaId =
|
|
143
|
+
msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
|
|
144
|
+
const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
|
|
145
|
+
const platformTs = Number(msg.messageTimestamp) * 1000;
|
|
146
|
+
pushMessage(chat.chatId, {
|
|
147
|
+
msgId,
|
|
148
|
+
senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
|
|
149
|
+
senderName,
|
|
150
|
+
senderHandle: canonicalId(identity),
|
|
151
|
+
text,
|
|
152
|
+
// The platform timestamp, so a catch-up message recorded late still
|
|
153
|
+
// reads in true order; Date.now() only when Baileys omits it.
|
|
154
|
+
timestamp:
|
|
155
|
+
Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
|
|
156
|
+
...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
|
|
157
|
+
...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
|
|
158
|
+
});
|
|
159
|
+
return { ...admitted, chat, msgId, text, media, senderName, platformTs };
|
|
160
|
+
}
|
|
161
|
+
|
|
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
|
+
/**
|
|
204
|
+
* Catch-up messages (queued while the daemon was down) get a reply
|
|
205
|
+
* turn only while fresh; stale ones are already recorded and the next
|
|
206
|
+
* live turn reads them from history.
|
|
207
|
+
*/
|
|
208
|
+
function catchUpDeservesReply(inbound: RecordedMessage): boolean {
|
|
209
|
+
const { chat, senderName } = inbound;
|
|
210
|
+
if (!shouldReplyToCatchUp(inbound.platformTs)) {
|
|
211
|
+
log(
|
|
212
|
+
"whatsapp",
|
|
213
|
+
`[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
|
|
214
|
+
);
|
|
215
|
+
recordMessageProcessed();
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
log(
|
|
219
|
+
"whatsapp",
|
|
220
|
+
`[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
|
|
221
|
+
);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function onTurnEvent(
|
|
226
|
+
runtime: WhatsAppRuntime,
|
|
227
|
+
chat: WhatsAppChatInfo,
|
|
228
|
+
event: AgentEvent,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
switch (event.type) {
|
|
231
|
+
case "tool_call": {
|
|
232
|
+
const input = toolInputToRecord(event.name, event.input);
|
|
233
|
+
const detail = (input.description ??
|
|
234
|
+
input.command ??
|
|
235
|
+
input.action ??
|
|
236
|
+
input.query ??
|
|
237
|
+
"") as string;
|
|
238
|
+
log(
|
|
239
|
+
"whatsapp",
|
|
240
|
+
` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
|
|
241
|
+
);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
// Progress prose and the end-of-turn trailing-text fallback.
|
|
245
|
+
// Without this, prose-only turns are silently dropped.
|
|
246
|
+
case "assistant_message": {
|
|
247
|
+
const sock = runtime.sock;
|
|
248
|
+
if (!event.text.trim() || !sock) break;
|
|
249
|
+
try {
|
|
250
|
+
await sendText({ sock, gateway: runtime.gateway }, chat, event.text);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
logError(
|
|
253
|
+
"whatsapp",
|
|
254
|
+
`onEvent delivery failed: ${err instanceof Error ? err.message : err}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function runInboundTurn(
|
|
263
|
+
runtime: WhatsAppRuntime,
|
|
264
|
+
inbound: RecordedMessage,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const { chat, text, media, msgId, senderName, identity, isGroup, jid } =
|
|
267
|
+
inbound;
|
|
268
|
+
const preview = text || `(${media?.type ?? "media"})`;
|
|
269
|
+
log(
|
|
270
|
+
"whatsapp",
|
|
271
|
+
`[${chat.chatId}] [${senderName}]: ${preview.slice(0, 80)}${preview.length > 80 ? "..." : ""}`,
|
|
272
|
+
);
|
|
273
|
+
appendDailyLog(senderName, preview, {
|
|
274
|
+
chatTitle: chat.title,
|
|
275
|
+
username: canonicalId(identity) ?? bareId(jid),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// The model addresses messages by numeric id (react/reply/edit), so the
|
|
279
|
+
// id travels with the text the same way the other frontends do it.
|
|
280
|
+
const mediaNote = media
|
|
281
|
+
? `\n[attached ${media.type}: ${media.filePath}]`
|
|
282
|
+
: "";
|
|
283
|
+
const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
|
|
284
|
+
|
|
285
|
+
const runTurn = () =>
|
|
286
|
+
execute({
|
|
287
|
+
chatId: chat.chatId,
|
|
288
|
+
numericChatId: chat.numericChatId,
|
|
289
|
+
prompt,
|
|
290
|
+
senderName,
|
|
291
|
+
isGroup,
|
|
292
|
+
source: "message",
|
|
293
|
+
onEvent: (event) => onTurnEvent(runtime, chat, event),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await runTurnWithRecovery({
|
|
297
|
+
chatId: chat.chatId,
|
|
298
|
+
senderName,
|
|
299
|
+
runTurn,
|
|
300
|
+
sendErrorText: async (text) => {
|
|
301
|
+
const sock = runtime.sock;
|
|
302
|
+
if (!sock) return;
|
|
303
|
+
try {
|
|
304
|
+
await sendText({ sock, gateway: runtime.gateway }, chat, text);
|
|
305
|
+
} catch (sendErr) {
|
|
306
|
+
logError(
|
|
307
|
+
"whatsapp",
|
|
308
|
+
`error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function handleInbound(
|
|
316
|
+
runtime: WhatsAppRuntime,
|
|
317
|
+
msg: WAMessage,
|
|
318
|
+
options: InboundOptions = {},
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
const admitted = await admitInbound(runtime, msg);
|
|
321
|
+
if (!admitted) return;
|
|
322
|
+
const inbound = await recordInbound(runtime, msg, admitted);
|
|
323
|
+
if (!inbound) return;
|
|
324
|
+
if (await handleSlashCommand(runtime, inbound)) return;
|
|
325
|
+
if (options.catchUp && !catchUpDeservesReply(inbound)) return;
|
|
326
|
+
await runInboundTurn(runtime, inbound);
|
|
327
|
+
}
|