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
|
@@ -12,59 +12,27 @@
|
|
|
12
12
|
*
|
|
13
13
|
* SEND: the model's delivery tools route through the gateway into
|
|
14
14
|
* `actions/`, which owns the whole WhatsApp API surface.
|
|
15
|
+
*
|
|
16
|
+
* This file is wiring only: it constructs the shared runtime (runtime.ts)
|
|
17
|
+
* and owns the frontend lifecycle. The behaviour lives in the modules —
|
|
18
|
+
* access (allow-lists), inbound (one message → one turn), connection
|
|
19
|
+
* (socket lifecycle, reconnect, park-until-paired).
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
|
-
import { rmSync } from "node:fs";
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
-
import { resolve as resolvePath } from "node:path";
|
|
20
|
-
import makeWASocket, {
|
|
21
|
-
isJidGroup,
|
|
22
|
-
type WAMessage,
|
|
23
|
-
type WASocket,
|
|
24
|
-
} from "baileys";
|
|
25
|
-
import qrcode from "qrcode-terminal";
|
|
26
22
|
import type { TalonConfig } from "../../util/config.js";
|
|
27
23
|
import type { ContextManager } from "../../core/types.js";
|
|
28
24
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
29
25
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
30
|
-
import {
|
|
31
|
-
import { execute } from "../../core/engine/dispatcher.js";
|
|
32
|
-
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
33
|
-
import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
|
|
34
|
-
import { performSessionReset } from "../shared/session-status.js";
|
|
35
|
-
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
36
|
-
import { pushMessage, maxMsgIdForChatPrefix } from "../../storage/history.js";
|
|
37
|
-
import {
|
|
38
|
-
recordMessageProcessed,
|
|
39
|
-
recordMessageReceived,
|
|
40
|
-
} from "../../util/watchdog.js";
|
|
26
|
+
import { maxMsgIdForChatPrefix } from "../../storage/history.js";
|
|
41
27
|
import { createWhatsAppActionHandler } from "./actions/index.js";
|
|
42
|
-
import {
|
|
43
|
-
bareId,
|
|
44
|
-
canonicalId,
|
|
45
|
-
identityAllowed,
|
|
46
|
-
resolveIdentity,
|
|
47
|
-
} from "./identity.js";
|
|
48
28
|
import { sendText, setWhatsAppBotName } from "./actions/shared.js";
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
lookupByWaId,
|
|
52
|
-
rememberMessage,
|
|
53
|
-
seedMessageStore,
|
|
54
|
-
} from "./message-store.js";
|
|
55
|
-
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
56
|
-
import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
57
|
-
import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
|
|
58
|
-
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
59
|
-
import { makeWaLogger } from "./wa-logger.js";
|
|
60
|
-
import { notifyAdmin } from "../../core/notify.js";
|
|
29
|
+
import { seedMessageStore } from "./message-store.js";
|
|
30
|
+
import { flushAuthWrites } from "./auth-state.js";
|
|
61
31
|
import { registerPairingProvider } from "../../core/pairing-broker.js";
|
|
62
32
|
import { beginPairingAttempt } from "./pairing-service.js";
|
|
63
|
-
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
type WhatsAppChatInfo,
|
|
67
|
-
} from "./registry.js";
|
|
33
|
+
import { runConnectionLoop } from "./connection.js";
|
|
34
|
+
import { lookupWhatsAppChat, type WhatsAppChatInfo } from "./registry.js";
|
|
35
|
+
import { createWhatsAppRuntime } from "./runtime.js";
|
|
68
36
|
|
|
69
37
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
70
38
|
|
|
@@ -79,39 +47,13 @@ export type WhatsAppFrontend = {
|
|
|
79
47
|
stop: () => Promise<void>;
|
|
80
48
|
};
|
|
81
49
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/** Reconnect backoff: WhatsApp throttles a client that hammers it. */
|
|
92
|
-
const RECONNECT_BASE_MS = 2_000;
|
|
93
|
-
const RECONNECT_MAX_MS = 60_000;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* How long a group's membership answer is trusted. Group metadata costs a
|
|
97
|
-
* round-trip, and the `with-allowed-user` policy would otherwise pay it on
|
|
98
|
-
* every inbound message; memberships change on the order of days.
|
|
99
|
-
*/
|
|
100
|
-
const GROUP_POLICY_CACHE_MS = 10 * 60_000;
|
|
101
|
-
|
|
102
|
-
/** Plain text of an inbound message, across the wrappers WhatsApp uses. */
|
|
103
|
-
function extractText(msg: WAMessage): string {
|
|
104
|
-
const m = msg.message;
|
|
105
|
-
if (!m) return "";
|
|
106
|
-
return (
|
|
107
|
-
m.conversation ??
|
|
108
|
-
m.extendedTextMessage?.text ??
|
|
109
|
-
m.imageMessage?.caption ??
|
|
110
|
-
m.videoMessage?.caption ??
|
|
111
|
-
m.documentMessage?.caption ??
|
|
112
|
-
m.documentWithCaptionMessage?.message?.documentMessage?.caption ??
|
|
113
|
-
""
|
|
114
|
-
);
|
|
50
|
+
function chatFor(numericChatId: number): WhatsAppChatInfo | null {
|
|
51
|
+
const info = lookupWhatsAppChat(numericChatId);
|
|
52
|
+
if (!info) {
|
|
53
|
+
logWarn("whatsapp", `No JID registered for chat ${numericChatId}`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return info;
|
|
115
57
|
}
|
|
116
58
|
|
|
117
59
|
// ── Frontend factory ─────────────────────────────────────────────────────────
|
|
@@ -120,25 +62,7 @@ export function createWhatsAppFrontend(
|
|
|
120
62
|
config: TalonConfig,
|
|
121
63
|
gateway: Gateway,
|
|
122
64
|
): WhatsAppFrontend {
|
|
123
|
-
const
|
|
124
|
-
allowedJids: [],
|
|
125
|
-
allowedGroups: [],
|
|
126
|
-
groupPolicy: "listed",
|
|
127
|
-
respondMode: "mention",
|
|
128
|
-
sendReadReceipts: true,
|
|
129
|
-
...((config as Record<string, unknown>).whatsapp as
|
|
130
|
-
Partial<WhatsAppSettings> | undefined),
|
|
131
|
-
};
|
|
132
|
-
const allowedDms = new Set(settings.allowedJids.map(bareId));
|
|
133
|
-
const allowedGroups = new Set(settings.allowedGroups.map(bareId));
|
|
134
|
-
|
|
135
|
-
let sock: WASocket | null = null;
|
|
136
|
-
let stopping = false;
|
|
137
|
-
let reconnectDelay = RECONNECT_BASE_MS;
|
|
138
|
-
/** One "not linked" admin note per outage, not one per QR window. */
|
|
139
|
-
let unpairedNotified = false;
|
|
140
|
-
/** Our own ids (phone and LID), once connected — for mention detection. */
|
|
141
|
-
let selfIds: string[] = [];
|
|
65
|
+
const runtime = createWhatsAppRuntime(config, gateway);
|
|
142
66
|
|
|
143
67
|
const context: ContextManager = {
|
|
144
68
|
acquire: (chatId: number, stringId?: string) =>
|
|
@@ -147,456 +71,13 @@ export function createWhatsAppFrontend(
|
|
|
147
71
|
getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
|
|
148
72
|
};
|
|
149
73
|
|
|
150
|
-
function chatFor(numericChatId: number): WhatsAppChatInfo | null {
|
|
151
|
-
const info = lookupWhatsAppChat(numericChatId);
|
|
152
|
-
if (!info) {
|
|
153
|
-
logWarn("whatsapp", `No JID registered for chat ${numericChatId}`);
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
return info;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Cached `groupPolicy` verdicts, keyed by group JID. */
|
|
160
|
-
const groupAllowCache = new Map<string, { allowed: boolean; at: number }>();
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* May the bot act in this group? `allowedGroups` is always honoured;
|
|
164
|
-
* beyond it the policy decides, and "with-allowed-user" asks WhatsApp
|
|
165
|
-
* who is in the group (cached — see GROUP_POLICY_CACHE_MS).
|
|
166
|
-
*/
|
|
167
|
-
async function isGroupAllowed(jid: string): Promise<boolean> {
|
|
168
|
-
if (allowedGroups.has(bareId(jid))) return true;
|
|
169
|
-
if (settings.groupPolicy === "listed") return false;
|
|
170
|
-
if (settings.groupPolicy === "all") return true;
|
|
171
|
-
|
|
172
|
-
const cached = groupAllowCache.get(jid);
|
|
173
|
-
if (cached && Date.now() - cached.at < GROUP_POLICY_CACHE_MS) {
|
|
174
|
-
return cached.allowed;
|
|
175
|
-
}
|
|
176
|
-
let allowed = false;
|
|
177
|
-
try {
|
|
178
|
-
const meta = await sock!.groupMetadata(jid);
|
|
179
|
-
// A participant is listed by whichever form the group uses, so
|
|
180
|
-
// check every id WhatsApp gives us for them.
|
|
181
|
-
allowed = meta.participants.some((p) =>
|
|
182
|
-
[p.id, p.lid, p.phoneNumber]
|
|
183
|
-
.filter((id): id is string => Boolean(id))
|
|
184
|
-
.some((id) => allowedDms.has(bareId(id))),
|
|
185
|
-
);
|
|
186
|
-
} catch (err) {
|
|
187
|
-
// A metadata failure must not silently open the group up.
|
|
188
|
-
logWarn(
|
|
189
|
-
"whatsapp",
|
|
190
|
-
`Group policy check failed for ${jid}: ${err instanceof Error ? err.message : err}`,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
groupAllowCache.set(jid, { allowed, at: Date.now() });
|
|
194
|
-
return allowed;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Is this group message addressed to us — @mentioned or quoting us? */
|
|
198
|
-
function isAddressedToSelf(msg: WAMessage): boolean {
|
|
199
|
-
if (selfIds.length === 0) return false;
|
|
200
|
-
const ctx = msg.message?.extendedTextMessage?.contextInfo;
|
|
201
|
-
const isSelf = (j: string): boolean => selfIds.includes(bareId(j));
|
|
202
|
-
if ((ctx?.mentionedJid ?? []).some(isSelf)) return true;
|
|
203
|
-
return Boolean(ctx?.participant && isSelf(ctx.participant));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function handleInbound(
|
|
207
|
-
msg: WAMessage,
|
|
208
|
-
opts: { catchUp?: boolean } = {},
|
|
209
|
-
): Promise<void> {
|
|
210
|
-
const jid = msg.key.remoteJid;
|
|
211
|
-
// `fromMe` covers our own sends echoing back; status@broadcast is the
|
|
212
|
-
// Stories feed, which is not a conversation.
|
|
213
|
-
if (!jid || jid === "status@broadcast" || msg.key.fromMe) return;
|
|
214
|
-
|
|
215
|
-
const isGroup = Boolean(isJidGroup(jid));
|
|
216
|
-
// The sender may be addressed by phone number or by LID depending on
|
|
217
|
-
// their privacy settings; resolve both before matching an allowlist
|
|
218
|
-
// that is written in phone numbers.
|
|
219
|
-
const senderJid = msg.key.participant || jid;
|
|
220
|
-
const identity = await resolveIdentity(
|
|
221
|
-
sock,
|
|
222
|
-
senderJid,
|
|
223
|
-
msg.key.participantAlt ?? msg.key.remoteJidAlt,
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
// ── Access gates: the allowlists are the entire permission model ──
|
|
227
|
-
if (isGroup) {
|
|
228
|
-
if (!(await isGroupAllowed(jid))) return;
|
|
229
|
-
if (settings.respondMode === "mention" && !isAddressedToSelf(msg)) return;
|
|
230
|
-
} else if (!identityAllowed(identity, allowedDms)) {
|
|
231
|
-
log(
|
|
232
|
-
"whatsapp",
|
|
233
|
-
`Ignoring DM from unlisted ${identity.ids.join("/") || bareId(jid)}`,
|
|
234
|
-
);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const text = extractText(msg).trim();
|
|
239
|
-
// Group chats key on the group JID; DMs key on the person, so the
|
|
240
|
-
// thread survives WhatsApp switching addressing form.
|
|
241
|
-
const chat = registerWhatsAppChat(
|
|
242
|
-
jid,
|
|
243
|
-
undefined,
|
|
244
|
-
isGroup ? undefined : canonicalId(identity),
|
|
245
|
-
);
|
|
246
|
-
const senderName = msg.pushName || canonicalId(identity) || "user";
|
|
247
|
-
const msgId = rememberMessage({
|
|
248
|
-
key: msg.key,
|
|
249
|
-
chatId: chat.chatId,
|
|
250
|
-
message: msg,
|
|
251
|
-
text,
|
|
252
|
-
senderName,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// Media is saved before the turn so the model can open the file by
|
|
256
|
-
// path in the same turn it's told about it.
|
|
257
|
-
const media = await saveInboundMedia(msg, chat.chatId, msgId, senderName);
|
|
258
|
-
if (!text && !media) return; // reaction, receipt, or an unsupported type
|
|
259
|
-
|
|
260
|
-
recordMessageReceived();
|
|
261
|
-
if (settings.sendReadReceipts) {
|
|
262
|
-
sock?.readMessages([msg.key]).catch(() => {});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Recorded for read_chat_history / search_chat_history, which the core
|
|
266
|
-
// serves from this store for frontends without a platform history API.
|
|
267
|
-
const replyToWaId =
|
|
268
|
-
msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
|
|
269
|
-
const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
|
|
270
|
-
const platformTs = Number(msg.messageTimestamp) * 1000;
|
|
271
|
-
pushMessage(chat.chatId, {
|
|
272
|
-
msgId,
|
|
273
|
-
senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
|
|
274
|
-
senderName,
|
|
275
|
-
senderHandle: canonicalId(identity),
|
|
276
|
-
text,
|
|
277
|
-
// The platform timestamp, so a catch-up message recorded late still
|
|
278
|
-
// reads in true order; Date.now() only when Baileys omits it.
|
|
279
|
-
timestamp:
|
|
280
|
-
Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
|
|
281
|
-
...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
|
|
282
|
-
...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// ── Slash commands ──
|
|
286
|
-
const trimmed = text.toLowerCase();
|
|
287
|
-
if (trimmed === "/reset") {
|
|
288
|
-
await performSessionReset(
|
|
289
|
-
chat.chatId,
|
|
290
|
-
resolveChatBackend(chat.chatId, gateway.backend),
|
|
291
|
-
// The local history store is WhatsApp's only chat record — a
|
|
292
|
-
// reset clears the model's session, not the conversation log.
|
|
293
|
-
{ keepHistory: true },
|
|
294
|
-
);
|
|
295
|
-
log("whatsapp", `Session reset by ${senderName}`);
|
|
296
|
-
if (sock) {
|
|
297
|
-
await sendText({ sock, gateway }, chat, "Session cleared.").catch(
|
|
298
|
-
() => {},
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
recordMessageProcessed();
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (trimmed === "/help") {
|
|
305
|
-
if (sock) {
|
|
306
|
-
await sendText(
|
|
307
|
-
{ sock, gateway },
|
|
308
|
-
chat,
|
|
309
|
-
"*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
|
|
310
|
-
).catch(() => {});
|
|
311
|
-
}
|
|
312
|
-
recordMessageProcessed();
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Catch-up messages (queued while the daemon was down) get a reply
|
|
317
|
-
// turn only while fresh; stale ones are already recorded above and
|
|
318
|
-
// the next live turn reads them from history.
|
|
319
|
-
if (opts.catchUp) {
|
|
320
|
-
if (!shouldReplyToCatchUp(platformTs)) {
|
|
321
|
-
log(
|
|
322
|
-
"whatsapp",
|
|
323
|
-
`[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
|
|
324
|
-
);
|
|
325
|
-
recordMessageProcessed();
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
log(
|
|
329
|
-
"whatsapp",
|
|
330
|
-
`[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const preview = text || `(${media?.type ?? "media"})`;
|
|
335
|
-
log(
|
|
336
|
-
"whatsapp",
|
|
337
|
-
`[${chat.chatId}] [${senderName}]: ${preview.slice(0, 80)}${preview.length > 80 ? "..." : ""}`,
|
|
338
|
-
);
|
|
339
|
-
appendDailyLog(senderName, preview, {
|
|
340
|
-
chatTitle: chat.title,
|
|
341
|
-
username: canonicalId(identity) ?? bareId(jid),
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// The model addresses messages by numeric id (react/reply/edit), so the
|
|
345
|
-
// id travels with the text the same way the other frontends do it.
|
|
346
|
-
const mediaNote = media
|
|
347
|
-
? `\n[attached ${media.type}: ${media.filePath}]`
|
|
348
|
-
: "";
|
|
349
|
-
const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
|
|
350
|
-
|
|
351
|
-
const runTurn = () =>
|
|
352
|
-
execute({
|
|
353
|
-
chatId: chat.chatId,
|
|
354
|
-
numericChatId: chat.numericChatId,
|
|
355
|
-
prompt,
|
|
356
|
-
senderName,
|
|
357
|
-
isGroup,
|
|
358
|
-
source: "message",
|
|
359
|
-
onEvent: async (event) => {
|
|
360
|
-
switch (event.type) {
|
|
361
|
-
case "tool_call": {
|
|
362
|
-
const input = toolInputToRecord(event.name, event.input);
|
|
363
|
-
const detail = (input.description ??
|
|
364
|
-
input.command ??
|
|
365
|
-
input.action ??
|
|
366
|
-
input.query ??
|
|
367
|
-
"") as string;
|
|
368
|
-
log(
|
|
369
|
-
"whatsapp",
|
|
370
|
-
` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
|
|
371
|
-
);
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
374
|
-
// Progress prose and the end-of-turn trailing-text fallback.
|
|
375
|
-
// Without this, prose-only turns are silently dropped.
|
|
376
|
-
case "assistant_message": {
|
|
377
|
-
if (!event.text.trim() || !sock) break;
|
|
378
|
-
try {
|
|
379
|
-
await sendText({ sock, gateway }, chat, event.text);
|
|
380
|
-
} catch (err) {
|
|
381
|
-
logError(
|
|
382
|
-
"whatsapp",
|
|
383
|
-
`onEvent delivery failed: ${err instanceof Error ? err.message : err}`,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
break;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
await runTurnWithRecovery({
|
|
393
|
-
chatId: chat.chatId,
|
|
394
|
-
senderName,
|
|
395
|
-
runTurn,
|
|
396
|
-
sendErrorText: async (text) => {
|
|
397
|
-
if (!sock) return;
|
|
398
|
-
try {
|
|
399
|
-
await sendText({ sock, gateway }, chat, text);
|
|
400
|
-
} catch (sendErr) {
|
|
401
|
-
logError(
|
|
402
|
-
"whatsapp",
|
|
403
|
-
`error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
},
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
411
|
-
async function connectOnce(): Promise<
|
|
412
|
-
"reconnect" | "logged-out" | "unpaired" | "stop"
|
|
413
|
-
> {
|
|
414
|
-
// Atomic replacement for Baileys' useMultiFileAuthState — same disk
|
|
415
|
-
// format, torn-write-proof (see auth-state.ts for why that matters).
|
|
416
|
-
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
417
|
-
const socket = makeWASocket({
|
|
418
|
-
auth: state,
|
|
419
|
-
logger: makeWaLogger(),
|
|
420
|
-
markOnlineOnConnect: false,
|
|
421
|
-
// The account is a bot: announcing "online" would suppress the
|
|
422
|
-
// phone's own notifications for the human who owns the number.
|
|
423
|
-
//
|
|
424
|
-
// 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
|
|
425
|
-
// a ~2½-minute socket lifetime in pairing mode, shorter than it
|
|
426
|
-
// takes a human to pick up their phone and type the code.
|
|
427
|
-
qrTimeout: 120_000,
|
|
428
|
-
// OpenClaw's production timings: the Baileys 20s connect timeout
|
|
429
|
-
// is tight on a loaded box, and a slightly faster keepalive spots
|
|
430
|
-
// a dead transport sooner.
|
|
431
|
-
connectTimeoutMs: 60_000,
|
|
432
|
-
keepAliveIntervalMs: 25_000,
|
|
433
|
-
});
|
|
434
|
-
sock = socket;
|
|
435
|
-
socket.ev.on("creds.update", saveCreds);
|
|
436
|
-
|
|
437
|
-
socket.ev.on("messages.upsert", ({ messages, type }) => {
|
|
438
|
-
// "notify" is a live message. "append" is everything delivered out
|
|
439
|
-
// of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
|
|
440
|
-
// (Baileys marks offline-queued nodes as append), but also our own
|
|
441
|
-
// sends echoing back and newsletter posts, which handleInbound's
|
|
442
|
-
// fromMe/allowlist gates drop. Dropping append wholesale meant any
|
|
443
|
-
// message sent during a restart simply vanished: never recorded,
|
|
444
|
-
// never answered. Appends are processed as catch-up: always
|
|
445
|
-
// recorded, replied to only while fresh.
|
|
446
|
-
if (type !== "notify" && type !== "append") return;
|
|
447
|
-
const catchUp = type === "append";
|
|
448
|
-
for (const msg of messages) {
|
|
449
|
-
void handleInbound(msg, { catchUp }).catch((err) => {
|
|
450
|
-
logError(
|
|
451
|
-
"whatsapp",
|
|
452
|
-
`inbound handler failed: ${err instanceof Error ? err.message : err}`,
|
|
453
|
-
);
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
return new Promise((resolve) => {
|
|
459
|
-
socket.ev.on("connection.update", (update) => {
|
|
460
|
-
const { connection, lastDisconnect, qr } = update;
|
|
461
|
-
|
|
462
|
-
if (qr && !state.creds.registered) {
|
|
463
|
-
// The frontend NEVER requests pairing codes on its own — that
|
|
464
|
-
// loop once burned ~30 codes in 80 minutes and rate-limited
|
|
465
|
-
// the account. Pairing is on demand via /whatsapp pair
|
|
466
|
-
// (core/pairing-broker.ts). The terminal QR stays for
|
|
467
|
-
// foreground first-run setups without a Telegram admin.
|
|
468
|
-
log(
|
|
469
|
-
"whatsapp",
|
|
470
|
-
"Not linked — pair on demand with /whatsapp pair (Telegram), " +
|
|
471
|
-
"or scan below: WhatsApp → Linked devices → Link a device",
|
|
472
|
-
);
|
|
473
|
-
qrcode.generate(qr, { small: true });
|
|
474
|
-
if (!unpairedNotified) {
|
|
475
|
-
unpairedNotified = true;
|
|
476
|
-
void notifyAdmin(
|
|
477
|
-
"📱 WhatsApp is not linked. Send /whatsapp pair when you're " +
|
|
478
|
-
"ready and I'll reply with a QR to scan.",
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (connection === "open") {
|
|
484
|
-
// Both forms: a group can @-mention us by either.
|
|
485
|
-
selfIds = [socket.user?.id, socket.user?.lid]
|
|
486
|
-
.filter((id): id is string => Boolean(id))
|
|
487
|
-
.map(bareId);
|
|
488
|
-
reconnectDelay = RECONNECT_BASE_MS;
|
|
489
|
-
if (unpairedNotified) {
|
|
490
|
-
void notifyAdmin("✅ WhatsApp linked and connected.");
|
|
491
|
-
}
|
|
492
|
-
unpairedNotified = false;
|
|
493
|
-
log(
|
|
494
|
-
"whatsapp",
|
|
495
|
-
`Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
if (connection === "close") {
|
|
500
|
-
const code = (
|
|
501
|
-
lastDisconnect?.error as
|
|
502
|
-
{ output?: { statusCode?: number } } | undefined
|
|
503
|
-
)?.output?.statusCode;
|
|
504
|
-
if (stopping) return resolve("stop");
|
|
505
|
-
const disposition = classifyClose(code, state.creds.registered);
|
|
506
|
-
switch (disposition.kind) {
|
|
507
|
-
case "pairing-accepted":
|
|
508
|
-
// 515 right after a pairing code/QR is SUCCESS, not an
|
|
509
|
-
// error: WhatsApp requires one reconnect with the same
|
|
510
|
-
// credentials to complete the login.
|
|
511
|
-
log(
|
|
512
|
-
"whatsapp",
|
|
513
|
-
"Pairing accepted (515) — reconnecting to complete login",
|
|
514
|
-
);
|
|
515
|
-
reconnectDelay = RECONNECT_BASE_MS;
|
|
516
|
-
return resolve("reconnect");
|
|
517
|
-
case "replaced":
|
|
518
|
-
// Another socket owns this session (440). Fighting it with
|
|
519
|
-
// an instant reconnect just steals the session back and
|
|
520
|
-
// forth; sit out a full minute instead.
|
|
521
|
-
logWarn(
|
|
522
|
-
"whatsapp",
|
|
523
|
-
"Connection replaced by another client (440) — backing off",
|
|
524
|
-
);
|
|
525
|
-
reconnectDelay = REPLACED_BACKOFF_MS;
|
|
526
|
-
return resolve("reconnect");
|
|
527
|
-
case "logged-out":
|
|
528
|
-
return resolve("logged-out");
|
|
529
|
-
default:
|
|
530
|
-
// A socket that died without ever completing a login is a
|
|
531
|
-
// pairing window that expired — reconnecting would just open QR
|
|
532
|
-
// session after QR session against WhatsApp's servers.
|
|
533
|
-
// Park instead; /whatsapp pair opens the next one.
|
|
534
|
-
//
|
|
535
|
-
// Test with isPaired, not creds.registered: a QR-linked session
|
|
536
|
-
// never sets that flag, so the flag alone parks a healthy
|
|
537
|
-
// session on its first ordinary disconnect.
|
|
538
|
-
if (!isPaired(state.creds)) return resolve("unpaired");
|
|
539
|
-
log(
|
|
540
|
-
"whatsapp",
|
|
541
|
-
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
542
|
-
);
|
|
543
|
-
return resolve("reconnect");
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Wait until a manual pairing completes (event from pairing-lock) or
|
|
552
|
-
* registered creds appear on disk, polling slowly. Resolves promptly
|
|
553
|
-
* when the frontend is stopping.
|
|
554
|
-
*/
|
|
555
|
-
async function parkUntilPaired(): Promise<void> {
|
|
556
|
-
while (!stopping) {
|
|
557
|
-
if (!isManualPairingActive()) {
|
|
558
|
-
try {
|
|
559
|
-
const raw = await readFile(
|
|
560
|
-
resolvePath(dirs.whatsappAuth, "creds.json"),
|
|
561
|
-
"utf-8",
|
|
562
|
-
);
|
|
563
|
-
// Same trap as above: waiting on `registered` alone means a QR
|
|
564
|
-
// re-link can never end the park, because that flag stays false.
|
|
565
|
-
if (
|
|
566
|
-
isPaired(
|
|
567
|
-
JSON.parse(raw) as {
|
|
568
|
-
registered?: boolean;
|
|
569
|
-
me?: { id?: string } | null;
|
|
570
|
-
},
|
|
571
|
-
)
|
|
572
|
-
)
|
|
573
|
-
return;
|
|
574
|
-
} catch {
|
|
575
|
-
/* no creds yet — stay parked */
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
const paired = await new Promise<boolean>((r) => {
|
|
579
|
-
const off = onPairingComplete(() => {
|
|
580
|
-
clearTimeout(timer);
|
|
581
|
-
off();
|
|
582
|
-
r(true);
|
|
583
|
-
});
|
|
584
|
-
const timer = setTimeout(() => {
|
|
585
|
-
off();
|
|
586
|
-
r(false);
|
|
587
|
-
}, 30_000);
|
|
588
|
-
timer.unref?.();
|
|
589
|
-
});
|
|
590
|
-
if (paired) return;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
74
|
return {
|
|
595
75
|
name: "whatsapp",
|
|
596
76
|
context,
|
|
597
77
|
|
|
598
78
|
sendTyping: async (chatId: number) => {
|
|
599
79
|
const chat = chatFor(chatId);
|
|
80
|
+
const sock = runtime.sock;
|
|
600
81
|
if (!chat || !sock) return;
|
|
601
82
|
await sock.sendPresenceUpdate("composing", chat.jid).catch(() => {});
|
|
602
83
|
},
|
|
@@ -604,6 +85,7 @@ export function createWhatsAppFrontend(
|
|
|
604
85
|
sendMessage: async (chatId: number, text: string) => {
|
|
605
86
|
if (!text.trim()) return;
|
|
606
87
|
const chat = chatFor(chatId);
|
|
88
|
+
const sock = runtime.sock;
|
|
607
89
|
if (!chat || !sock) return;
|
|
608
90
|
try {
|
|
609
91
|
await sendText({ sock, gateway }, chat, text);
|
|
@@ -623,8 +105,8 @@ export function createWhatsAppFrontend(
|
|
|
623
105
|
// (/whatsapp pair on Telegram) through the core broker.
|
|
624
106
|
registerPairingProvider({
|
|
625
107
|
label: "WhatsApp",
|
|
626
|
-
isLinked: () => Boolean(sock?.user),
|
|
627
|
-
begin: () => beginPairingAttempt(settings.pairingNumber),
|
|
108
|
+
isLinked: () => Boolean(runtime.sock?.user),
|
|
109
|
+
begin: () => beginPairingAttempt(runtime.settings.pairingNumber),
|
|
628
110
|
});
|
|
629
111
|
// The in-memory message-id counter restarts at its base every boot,
|
|
630
112
|
// but history persists — seed it past what the table already holds
|
|
@@ -633,76 +115,23 @@ export function createWhatsAppFrontend(
|
|
|
633
115
|
seedMessageStore((maxMsgIdForChatPrefix("wa_") ?? 0) + 1);
|
|
634
116
|
gateway.registerFrontendHandler(
|
|
635
117
|
"whatsapp",
|
|
636
|
-
createWhatsAppActionHandler(() => sock, gateway),
|
|
118
|
+
createWhatsAppActionHandler(() => runtime.sock, gateway),
|
|
637
119
|
);
|
|
638
120
|
const port = await gateway.start(19876);
|
|
639
121
|
log("whatsapp", `Gateway on port ${port}`);
|
|
640
122
|
},
|
|
641
123
|
|
|
642
|
-
|
|
643
|
-
log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
|
|
644
|
-
while (!stopping) {
|
|
645
|
-
// A manual pairing attempt owns the auth dir: two sockets on one
|
|
646
|
-
// keypair corrupt it and burn rate-limited pairing attempts.
|
|
647
|
-
if (isManualPairingActive()) {
|
|
648
|
-
await new Promise((r) => setTimeout(r, 5_000));
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
let outcome: "reconnect" | "logged-out" | "unpaired" | "stop";
|
|
652
|
-
try {
|
|
653
|
-
outcome = await connectOnce();
|
|
654
|
-
} catch (err) {
|
|
655
|
-
logError(
|
|
656
|
-
"whatsapp",
|
|
657
|
-
`Socket error: ${err instanceof Error ? err.message : err}`,
|
|
658
|
-
);
|
|
659
|
-
outcome = "reconnect";
|
|
660
|
-
}
|
|
661
|
-
sock = null;
|
|
662
|
-
if (outcome === "stop" || stopping) break;
|
|
663
|
-
if (outcome === "logged-out") {
|
|
664
|
-
// These credentials are dead — WhatsApp unlinked the device.
|
|
665
|
-
// Wipe them and PARK. Re-pairing needs a human holding the
|
|
666
|
-
// phone, so it happens strictly on demand (Telegram's
|
|
667
|
-
// `/whatsapp pair`) — every automatic retry policy tried here,
|
|
668
|
-
// including a 30-minute backoff ladder, still burned codes
|
|
669
|
-
// against WhatsApp's rate limit until scans failed with
|
|
670
|
-
// "couldn't connect device" for everyone.
|
|
671
|
-
logWarn(
|
|
672
|
-
"whatsapp",
|
|
673
|
-
"Logged out by WhatsApp — parked until /whatsapp pair re-links",
|
|
674
|
-
);
|
|
675
|
-
void notifyAdmin(
|
|
676
|
-
"⚠️ WhatsApp unlinked this device. When you're ready to " +
|
|
677
|
-
"re-link, send /whatsapp pair and scan the QR I reply with. " +
|
|
678
|
-
"Nothing is retried until then.",
|
|
679
|
-
);
|
|
680
|
-
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
681
|
-
await parkUntilPaired();
|
|
682
|
-
reconnectDelay = RECONNECT_BASE_MS;
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
if (outcome === "unpaired") {
|
|
686
|
-
log("whatsapp", "Not paired — parked until /whatsapp pair links");
|
|
687
|
-
await parkUntilPaired();
|
|
688
|
-
reconnectDelay = RECONNECT_BASE_MS;
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
await new Promise((r) => setTimeout(r, reconnectDelay));
|
|
692
|
-
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
693
|
-
}
|
|
694
|
-
log("whatsapp", "WhatsApp connection loop ended");
|
|
695
|
-
},
|
|
124
|
+
start: () => runConnectionLoop(runtime),
|
|
696
125
|
|
|
697
126
|
async stop() {
|
|
698
|
-
stopping = true;
|
|
127
|
+
runtime.stopping = true;
|
|
699
128
|
registerPairingProvider(null);
|
|
700
129
|
try {
|
|
701
|
-
sock?.end(undefined);
|
|
130
|
+
runtime.sock?.end(undefined);
|
|
702
131
|
} catch {
|
|
703
132
|
/* already closed */
|
|
704
133
|
}
|
|
705
|
-
sock = null;
|
|
134
|
+
runtime.sock = null;
|
|
706
135
|
// Drain queued credential writes before the process exits — a key
|
|
707
136
|
// half-written at shutdown is invisible until the server starts
|
|
708
137
|
// rejecting stanzas with it.
|