talon-agent 3.25.3 → 3.27.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 +3 -1
- package/src/core/pairing-broker.ts +50 -0
- package/src/frontend/telegram/commands/definitions.ts +1 -0
- package/src/frontend/telegram/commands/index.ts +2 -0
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +102 -0
- package/src/frontend/whatsapp/actions/messaging.ts +34 -18
- package/src/frontend/whatsapp/actions/shared.ts +54 -1
- package/src/frontend/whatsapp/auth-state.ts +133 -0
- package/src/frontend/whatsapp/index.ts +148 -113
- package/src/frontend/whatsapp/pairing-lock.ts +44 -0
- package/src/frontend/whatsapp/pairing-service.ts +187 -0
- package/src/frontend/whatsapp/pairing.ts +38 -33
- package/src/frontend/whatsapp/wa-logger.ts +31 -0
- package/src/util/config.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
111
|
"@playwright/mcp": "0.0.79",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
|
113
|
+
"@types/qrcode": "^1.5.6",
|
|
113
114
|
"baileys": "^7.0.0-rc14",
|
|
114
115
|
"big-integer": "^1.6.52",
|
|
115
116
|
"cheerio": "^1.2.0",
|
|
@@ -126,6 +127,7 @@
|
|
|
126
127
|
"picocolors": "^1.1.1",
|
|
127
128
|
"pino": "^10.3.1",
|
|
128
129
|
"pino-pretty": "^13.1.3",
|
|
130
|
+
"qrcode": "^1.5.4",
|
|
129
131
|
"qrcode-terminal": "^0.12.0",
|
|
130
132
|
"telegram": "^2.26.22",
|
|
131
133
|
"tsx": "^4.21.0",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend-pairing broker — how one frontend drives another's device
|
|
3
|
+
* pairing without importing it.
|
|
4
|
+
*
|
|
5
|
+
* Concretely: WhatsApp needs a human to scan a QR / enter a code, and
|
|
6
|
+
* the human is reachable on Telegram. Frontends must not import each
|
|
7
|
+
* other, so this core seam (baileys-free, grammy-free) carries the
|
|
8
|
+
* handshake: the WhatsApp side registers a provider at composition time,
|
|
9
|
+
* the Telegram command consumes it on demand.
|
|
10
|
+
*
|
|
11
|
+
* Born from an outage: automatic re-pairing loops burned ~30 codes in
|
|
12
|
+
* 80 minutes and WhatsApp rate-limited the account ("couldn't connect
|
|
13
|
+
* device" on every scan). Pairing must be a deliberate act that happens
|
|
14
|
+
* exactly when the human is holding the phone — one bounded attempt per
|
|
15
|
+
* command, zero background retries.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PairingAttempt = {
|
|
19
|
+
/** PNG bytes of the QR to scan, when the flow produced one. */
|
|
20
|
+
qrPng?: Uint8Array;
|
|
21
|
+
/** Phone-number pairing code, when the account is configured for it. */
|
|
22
|
+
code?: string;
|
|
23
|
+
/** Resolves with the outcome of THIS attempt (bounded, never hangs). */
|
|
24
|
+
result: Promise<PairingOutcome>;
|
|
25
|
+
/** Abort the attempt early (user cancelled, command timed out). */
|
|
26
|
+
cancel: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PairingOutcome =
|
|
30
|
+
| { ok: true; identity: string }
|
|
31
|
+
| { ok: false; reason: "expired" | "failed" | "cancelled"; detail?: string };
|
|
32
|
+
|
|
33
|
+
export type PairingProvider = {
|
|
34
|
+
/** Human-readable frontend name ("WhatsApp"). */
|
|
35
|
+
label: string;
|
|
36
|
+
/** True when the frontend is already linked and connected. */
|
|
37
|
+
isLinked: () => boolean;
|
|
38
|
+
/** Start ONE bounded pairing attempt. Rejects if one is already running. */
|
|
39
|
+
begin: () => Promise<PairingAttempt>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let provider: PairingProvider | null = null;
|
|
43
|
+
|
|
44
|
+
export function registerPairingProvider(p: PairingProvider | null): void {
|
|
45
|
+
provider = p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getPairingProvider(): PairingProvider | null {
|
|
49
|
+
return provider;
|
|
50
|
+
}
|
|
@@ -29,6 +29,7 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
29
29
|
{ command: "stop", description: "Stop the current response" },
|
|
30
30
|
{ command: "reset", description: "Clear session and start fresh" },
|
|
31
31
|
{ command: "restart", description: "Restart the bot (admin)" },
|
|
32
|
+
{ command: "whatsapp", description: "WhatsApp link status / pair (admin)" },
|
|
32
33
|
{ command: "usage", description: "Plan limits across every backend" },
|
|
33
34
|
{ command: "metrics", description: "Aggregate performance metrics" },
|
|
34
35
|
{
|
|
@@ -22,6 +22,7 @@ import { registerInfoCommands } from "./info.js";
|
|
|
22
22
|
import { registerSessionCommands } from "./session.js";
|
|
23
23
|
import { registerSettingsCommands } from "./settings.js";
|
|
24
24
|
import { registerAdminCommands } from "./admin.js";
|
|
25
|
+
import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
|
|
25
26
|
|
|
26
27
|
export { TELEGRAM_COMMANDS, telegramCommandMenu } from "./definitions.js";
|
|
27
28
|
export { setAdminUserId } from "./state.js";
|
|
@@ -36,4 +37,5 @@ export function registerCommands(
|
|
|
36
37
|
registerSessionCommands(bot, deps);
|
|
37
38
|
registerSettingsCommands(bot, deps);
|
|
38
39
|
registerAdminCommands(bot, deps);
|
|
40
|
+
registerWhatsAppPairingCommand(bot);
|
|
39
41
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /whatsapp — drive WhatsApp device pairing from Telegram.
|
|
3
|
+
*
|
|
4
|
+
* `/whatsapp` reports link state; `/whatsapp pair` runs ONE bounded
|
|
5
|
+
* pairing attempt via the core pairing broker and replies with the QR as
|
|
6
|
+
* a photo (plus the phone-number code when the account has one
|
|
7
|
+
* configured). The human runs it when they are holding the phone —
|
|
8
|
+
* pairing is never automatic, because automatic retries are how the
|
|
9
|
+
* account got rate-limited into "couldn't connect device".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { InputFile, type Bot } from "grammy";
|
|
13
|
+
import { getPairingProvider } from "../../../core/pairing-broker.js";
|
|
14
|
+
import { log } from "../../../util/log.js";
|
|
15
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
16
|
+
|
|
17
|
+
export function registerWhatsAppPairingCommand(bot: Bot): void {
|
|
18
|
+
bot.command("whatsapp", async (ctx) => {
|
|
19
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
20
|
+
await ctx.reply("Not authorized.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const provider = getPairingProvider();
|
|
25
|
+
if (!provider) {
|
|
26
|
+
await ctx.reply(
|
|
27
|
+
'WhatsApp isn\'t enabled on this daemon — add "whatsapp" to the ' +
|
|
28
|
+
"frontend list and configure the whatsapp block, then restart.",
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const arg = (ctx.match ?? "").trim().toLowerCase();
|
|
34
|
+
if (arg !== "pair") {
|
|
35
|
+
await ctx.reply(
|
|
36
|
+
provider.isLinked()
|
|
37
|
+
? "✅ WhatsApp is linked and connected.\nSend /whatsapp pair to re-link."
|
|
38
|
+
: "📴 WhatsApp is not linked.\nSend /whatsapp pair when you're " +
|
|
39
|
+
"holding the phone — I'll reply with a QR to scan " +
|
|
40
|
+
"(WhatsApp → Linked devices → Link a device).",
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (provider.isLinked()) {
|
|
46
|
+
await ctx.reply(
|
|
47
|
+
"WhatsApp is already linked. Unlink it on the phone first if you " +
|
|
48
|
+
"want to re-pair.",
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let attempt;
|
|
54
|
+
try {
|
|
55
|
+
attempt = await provider.begin();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
await ctx.reply(
|
|
58
|
+
`Couldn't start pairing: ${err instanceof Error ? err.message : err}`,
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (attempt.qrPng) {
|
|
64
|
+
const codeLine = attempt.code
|
|
65
|
+
? `\nOr enter code: ${attempt.code} (Link with phone number instead)`
|
|
66
|
+
: "";
|
|
67
|
+
await ctx.replyWithPhoto(
|
|
68
|
+
new InputFile(attempt.qrPng, "whatsapp-qr.png"),
|
|
69
|
+
{
|
|
70
|
+
caption:
|
|
71
|
+
"Scan within ~2 minutes:\n" +
|
|
72
|
+
"WhatsApp → ⋮ → Linked devices → Link a device" +
|
|
73
|
+
codeLine,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
await ctx.reply(
|
|
78
|
+
"No QR came back — WhatsApp may still be rate-limiting. Try later.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const outcome = await attempt.result;
|
|
83
|
+
if (outcome.ok) {
|
|
84
|
+
log("bot", `WhatsApp paired via /whatsapp pair (${outcome.identity})`);
|
|
85
|
+
await ctx.reply(
|
|
86
|
+
`✅ Linked as ${outcome.identity}. WhatsApp is connecting now.`,
|
|
87
|
+
);
|
|
88
|
+
} else if (outcome.reason === "expired") {
|
|
89
|
+
await ctx.reply(
|
|
90
|
+
"⌛ That window expired without a successful scan. Run " +
|
|
91
|
+
"/whatsapp pair again when you're ready — if the phone says " +
|
|
92
|
+
'"couldn\'t connect device", WhatsApp is still rate-limiting; ' +
|
|
93
|
+
"wait an hour and try once more.",
|
|
94
|
+
);
|
|
95
|
+
} else if (outcome.reason !== "cancelled") {
|
|
96
|
+
await ctx.reply(
|
|
97
|
+
`❌ Pairing failed${outcome.detail ? `: ${outcome.detail}` : ""}. ` +
|
|
98
|
+
"Run /whatsapp pair to try again.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -17,7 +17,13 @@ import {
|
|
|
17
17
|
resolveKey,
|
|
18
18
|
} from "../message-store.js";
|
|
19
19
|
import { recordPin, listPins, forgetPin } from "../pins.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
resolveQuoted,
|
|
22
|
+
sendContent,
|
|
23
|
+
sendText,
|
|
24
|
+
tryAction,
|
|
25
|
+
boundedSend,
|
|
26
|
+
} from "./shared.js";
|
|
21
27
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
22
28
|
|
|
23
29
|
/** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
|
|
@@ -89,9 +95,11 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
89
95
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
90
96
|
// An empty emoji clears the reaction — WhatsApp's own semantics,
|
|
91
97
|
// and the only way to take one back.
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
await boundedSend("react", () =>
|
|
99
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
100
|
+
react: { text: String(body.emoji ?? ""), key: resolved.key },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
95
103
|
return { ok: true };
|
|
96
104
|
}),
|
|
97
105
|
|
|
@@ -103,10 +111,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
103
111
|
if (!text.trim()) {
|
|
104
112
|
return { ok: false, error: "edit_message: text is required" };
|
|
105
113
|
}
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
await boundedSend("edit", () =>
|
|
115
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
116
|
+
text: toWhatsAppText(text),
|
|
117
|
+
edit: resolved.key,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
110
120
|
return { ok: true, message_id: resolved.stored.msgId };
|
|
111
121
|
}),
|
|
112
122
|
|
|
@@ -114,7 +124,9 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
114
124
|
tryAction("delete_message", async () => {
|
|
115
125
|
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
116
126
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
117
|
-
await
|
|
127
|
+
await boundedSend("delete", () =>
|
|
128
|
+
ctx.sock.sendMessage(ctx.chat!.jid, { delete: resolved.key }),
|
|
129
|
+
);
|
|
118
130
|
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
119
131
|
return { ok: true };
|
|
120
132
|
}),
|
|
@@ -166,11 +178,13 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
166
178
|
const time =
|
|
167
179
|
PIN_DURATIONS.find((d) => d >= requested) ??
|
|
168
180
|
PIN_DURATIONS[PIN_DURATIONS.length - 1];
|
|
169
|
-
await
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
await boundedSend("pin", () =>
|
|
182
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
183
|
+
pin: resolved.key,
|
|
184
|
+
type: proto.PinInChat.Type.PIN_FOR_ALL,
|
|
185
|
+
time,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
174
188
|
recordPin(ctx.chat!.chatId, resolved.stored);
|
|
175
189
|
return { ok: true };
|
|
176
190
|
}),
|
|
@@ -179,10 +193,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
179
193
|
tryAction("unpin_message", async () => {
|
|
180
194
|
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
181
195
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
196
|
+
await boundedSend("unpin", () =>
|
|
197
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
198
|
+
pin: resolved.key,
|
|
199
|
+
type: proto.PinInChat.Type.UNPIN_FOR_ALL,
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
186
202
|
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
187
203
|
return { ok: true };
|
|
188
204
|
}),
|
|
@@ -163,6 +163,57 @@ function outboundTextMarker(content: AnyMessageContent): string {
|
|
|
163
163
|
return "";
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// ── Serialized, time-bounded sends ─────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// Ported from OpenClaw's socket-timing adapter. Two failure modes this
|
|
169
|
+
// removes, both observed live:
|
|
170
|
+
// - a send on a dying socket hanging a turn indefinitely ("timed out
|
|
171
|
+
// waiting for message" with nothing delivered and no error surfaced),
|
|
172
|
+
// - interleaved sends racing each other's Baileys internals.
|
|
173
|
+
// Every outbound WhatsApp operation goes through one FIFO per process
|
|
174
|
+
// with a hard timeout; on timeout the queue advances so later sends
|
|
175
|
+
// aren't wedged behind the dead one.
|
|
176
|
+
|
|
177
|
+
const SEND_TIMEOUT_MS = 60_000;
|
|
178
|
+
let sendTail: Promise<unknown> = Promise.resolve();
|
|
179
|
+
|
|
180
|
+
export class WhatsAppSendTimeoutError extends Error {
|
|
181
|
+
constructor(operation: string) {
|
|
182
|
+
super(
|
|
183
|
+
`WhatsApp ${operation} timed out after ${SEND_TIMEOUT_MS / 1000}s — ` +
|
|
184
|
+
`delivery state unknown (the socket may be dead or reconnecting)`,
|
|
185
|
+
);
|
|
186
|
+
this.name = "WhatsAppSendTimeoutError";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run one socket operation serialized behind every earlier one, bounded
|
|
192
|
+
* by SEND_TIMEOUT_MS. The timeout rejects THIS caller but releases the
|
|
193
|
+
* queue, so a wedged operation can't dam everything after it.
|
|
194
|
+
*/
|
|
195
|
+
export function boundedSend<T>(
|
|
196
|
+
operation: string,
|
|
197
|
+
run: () => Promise<T>,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
const prev = sendTail.catch(() => {});
|
|
200
|
+
const result = prev.then(() => {
|
|
201
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
202
|
+
return Promise.race([
|
|
203
|
+
run(),
|
|
204
|
+
new Promise<never>((_, reject) => {
|
|
205
|
+
timer = setTimeout(
|
|
206
|
+
() => reject(new WhatsAppSendTimeoutError(operation)),
|
|
207
|
+
SEND_TIMEOUT_MS,
|
|
208
|
+
);
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
}),
|
|
211
|
+
]).finally(() => clearTimeout(timer));
|
|
212
|
+
});
|
|
213
|
+
sendTail = result.catch(() => {});
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
166
217
|
/**
|
|
167
218
|
* Send one content payload, remember the resulting message so later
|
|
168
219
|
* tool calls can address it, and report its Talon numeric id.
|
|
@@ -173,7 +224,9 @@ export async function sendContent(
|
|
|
173
224
|
content: AnyMessageContent,
|
|
174
225
|
options: { quoted?: WAMessage } = {},
|
|
175
226
|
): Promise<ActionResult> {
|
|
176
|
-
const sent = await
|
|
227
|
+
const sent = await boundedSend("sendMessage", () =>
|
|
228
|
+
ctx.sock.sendMessage(chat.jid, content, options),
|
|
229
|
+
);
|
|
177
230
|
ctx.gateway.incrementMessages(chat.numericChatId);
|
|
178
231
|
if (!sent?.key) return { ok: true };
|
|
179
232
|
const text =
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic multi-file auth state for Baileys — a drop-in replacement for
|
|
3
|
+
* `useMultiFileAuthState` with two hardening changes ported from how
|
|
4
|
+
* OpenClaw persists WhatsApp credentials:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every write is ATOMIC (tmp + rename via write-file-atomic). The
|
|
7
|
+
* upstream helper uses a bare `writeFile`, so a daemon restart or
|
|
8
|
+
* crash mid-write tears creds.json or a signal-key file. A torn
|
|
9
|
+
* signal key is invisible until the server starts rejecting stanzas
|
|
10
|
+
* ("smax-invalid: stanza rejected — likely stale device"), which is
|
|
11
|
+
* the documented prelude to WhatsApp unlinking the device — the
|
|
12
|
+
* exact sequence the live deployment logged before its
|
|
13
|
+
* `device_removed` conflict.
|
|
14
|
+
* 2. Writes to the same file are serialized through a promise chain,
|
|
15
|
+
* and `flushAuthWrites()` lets shutdown drain the queue before the
|
|
16
|
+
* process exits, so `talon stop` can't strand a half-persisted key.
|
|
17
|
+
*
|
|
18
|
+
* The on-disk format is byte-compatible with upstream: same folder, same
|
|
19
|
+
* file names, same BufferJSON encoding — existing auth dirs just work.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdir, readFile, stat, unlink } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import writeFileAtomic from "write-file-atomic";
|
|
25
|
+
import { BufferJSON, initAuthCreds, proto } from "baileys";
|
|
26
|
+
import type { AuthenticationState, SignalDataTypeMap } from "baileys";
|
|
27
|
+
|
|
28
|
+
/** file path → tail of its write chain. Module-level: one dir per process. */
|
|
29
|
+
const writeTails = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
31
|
+
function enqueueWrite(
|
|
32
|
+
filePath: string,
|
|
33
|
+
task: () => Promise<void>,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const tail = (writeTails.get(filePath) ?? Promise.resolve())
|
|
36
|
+
.catch(() => {})
|
|
37
|
+
.then(task);
|
|
38
|
+
writeTails.set(
|
|
39
|
+
filePath,
|
|
40
|
+
tail.catch(() => {}),
|
|
41
|
+
);
|
|
42
|
+
return tail;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve when every queued auth write has settled. Call before exit. */
|
|
46
|
+
export async function flushAuthWrites(): Promise<void> {
|
|
47
|
+
await Promise.allSettled(writeTails.values());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Baileys' name mangling, kept identical for on-disk compatibility. */
|
|
51
|
+
function fixFileName(file: string): string {
|
|
52
|
+
return file.replace(/\//g, "__").replace(/:/g, "-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function useAtomicAuthState(folder: string): Promise<{
|
|
56
|
+
state: AuthenticationState;
|
|
57
|
+
saveCreds: () => Promise<void>;
|
|
58
|
+
}> {
|
|
59
|
+
const info = await stat(folder).catch(() => undefined);
|
|
60
|
+
if (info && !info.isDirectory()) {
|
|
61
|
+
throw new Error(`not a directory: ${folder}`);
|
|
62
|
+
}
|
|
63
|
+
if (!info) await mkdir(folder, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const writeData = (data: unknown, file: string): Promise<void> => {
|
|
66
|
+
const filePath = join(folder, fixFileName(file));
|
|
67
|
+
return enqueueWrite(filePath, () =>
|
|
68
|
+
writeFileAtomic(filePath, JSON.stringify(data, BufferJSON.replacer)),
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readData = async (file: string): Promise<unknown> => {
|
|
73
|
+
const filePath = join(folder, fixFileName(file));
|
|
74
|
+
// Reads wait for any pending write to that file, so a get() racing a
|
|
75
|
+
// set() sees the new value rather than the old file.
|
|
76
|
+
await (writeTails.get(filePath) ?? Promise.resolve()).catch(() => {});
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(filePath, "utf-8");
|
|
79
|
+
return JSON.parse(raw, BufferJSON.reviver);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const removeData = (file: string): Promise<void> => {
|
|
86
|
+
const filePath = join(folder, fixFileName(file));
|
|
87
|
+
return enqueueWrite(filePath, async () => {
|
|
88
|
+
await unlink(filePath).catch(() => {});
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const creds =
|
|
93
|
+
((await readData("creds.json")) as ReturnType<
|
|
94
|
+
typeof initAuthCreds
|
|
95
|
+
> | null) ?? initAuthCreds();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
state: {
|
|
99
|
+
creds,
|
|
100
|
+
keys: {
|
|
101
|
+
get: async <T extends keyof SignalDataTypeMap>(
|
|
102
|
+
type: T,
|
|
103
|
+
ids: string[],
|
|
104
|
+
) => {
|
|
105
|
+
const data: { [id: string]: SignalDataTypeMap[T] } = {};
|
|
106
|
+
await Promise.all(
|
|
107
|
+
ids.map(async (id) => {
|
|
108
|
+
let value = await readData(`${type}-${id}.json`);
|
|
109
|
+
if (type === "app-state-sync-key" && value) {
|
|
110
|
+
value = proto.Message.AppStateSyncKeyData.fromObject(value);
|
|
111
|
+
}
|
|
112
|
+
data[id] = value as SignalDataTypeMap[T];
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
return data;
|
|
116
|
+
},
|
|
117
|
+
set: async (data) => {
|
|
118
|
+
const tasks: Promise<void>[] = [];
|
|
119
|
+
for (const category in data) {
|
|
120
|
+
const entries = data[category as keyof SignalDataTypeMap];
|
|
121
|
+
for (const id in entries) {
|
|
122
|
+
const value = entries[id];
|
|
123
|
+
const file = `${category}-${id}.json`;
|
|
124
|
+
tasks.push(value ? writeData(value, file) : removeData(file));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await Promise.all(tasks);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
saveCreds: () => writeData(creds, "creds.json"),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { rmSync } from "node:fs";
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { resolve as resolvePath } from "node:path";
|
|
18
20
|
import makeWASocket, {
|
|
19
|
-
DisconnectReason,
|
|
20
21
|
isJidGroup,
|
|
21
|
-
useMultiFileAuthState,
|
|
22
22
|
type WAMessage,
|
|
23
23
|
type WASocket,
|
|
24
24
|
} from "baileys";
|
|
@@ -53,8 +53,13 @@ import {
|
|
|
53
53
|
seedMessageStore,
|
|
54
54
|
} from "./message-store.js";
|
|
55
55
|
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
56
|
-
import {
|
|
56
|
+
import { classifyClose, 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";
|
|
57
60
|
import { notifyAdmin } from "../../core/notify.js";
|
|
61
|
+
import { registerPairingProvider } from "../../core/pairing-broker.js";
|
|
62
|
+
import { beginPairingAttempt } from "./pairing-service.js";
|
|
58
63
|
import {
|
|
59
64
|
lookupWhatsAppChat,
|
|
60
65
|
registerWhatsAppChat,
|
|
@@ -109,32 +114,6 @@ function extractText(msg: WAMessage): string {
|
|
|
109
114
|
);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
/**
|
|
113
|
-
* Baileys logs through its own pino instance, far too chatty for a daemon
|
|
114
|
-
* that already has structured logging. Bridge warn+ into Talon's log and
|
|
115
|
-
* drop the rest. The shape is pino's minimal logger contract.
|
|
116
|
-
*/
|
|
117
|
-
function makeWaLogger(): never {
|
|
118
|
-
const fmt = (args: unknown[]): string =>
|
|
119
|
-
args
|
|
120
|
-
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
121
|
-
.join(" ")
|
|
122
|
-
.slice(0, 300);
|
|
123
|
-
const shim = {
|
|
124
|
-
level: "warn",
|
|
125
|
-
child: () => shim,
|
|
126
|
-
trace: () => {},
|
|
127
|
-
debug: () => {},
|
|
128
|
-
info: () => {},
|
|
129
|
-
warn: (...args: unknown[]) => logWarn("whatsapp", `baileys: ${fmt(args)}`),
|
|
130
|
-
error: (...args: unknown[]) =>
|
|
131
|
-
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
132
|
-
fatal: (...args: unknown[]) =>
|
|
133
|
-
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
134
|
-
};
|
|
135
|
-
return shim as never;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
117
|
// ── Frontend factory ─────────────────────────────────────────────────────────
|
|
139
118
|
|
|
140
119
|
export function createWhatsAppFrontend(
|
|
@@ -156,11 +135,8 @@ export function createWhatsAppFrontend(
|
|
|
156
135
|
let sock: WASocket | null = null;
|
|
157
136
|
let stopping = false;
|
|
158
137
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
159
|
-
/**
|
|
160
|
-
let
|
|
161
|
-
/** Codes issued this outage — throttles the admin notifications. */
|
|
162
|
-
let pairingCodesIssued = 0;
|
|
163
|
-
let lastPairingNotifyAt: number | undefined;
|
|
138
|
+
/** One "not linked" admin note per outage, not one per QR window. */
|
|
139
|
+
let unpairedNotified = false;
|
|
164
140
|
/** Our own ids (phone and LID), once connected — for mention detection. */
|
|
165
141
|
let selfIds: string[] = [];
|
|
166
142
|
|
|
@@ -432,8 +408,12 @@ export function createWhatsAppFrontend(
|
|
|
432
408
|
}
|
|
433
409
|
|
|
434
410
|
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
435
|
-
async function connectOnce(): Promise<
|
|
436
|
-
|
|
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);
|
|
437
417
|
const socket = makeWASocket({
|
|
438
418
|
auth: state,
|
|
439
419
|
logger: makeWaLogger(),
|
|
@@ -445,6 +425,11 @@ export function createWhatsAppFrontend(
|
|
|
445
425
|
// a ~2½-minute socket lifetime in pairing mode, shorter than it
|
|
446
426
|
// takes a human to pick up their phone and type the code.
|
|
447
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,
|
|
448
433
|
});
|
|
449
434
|
sock = socket;
|
|
450
435
|
socket.ev.on("creds.update", saveCreds);
|
|
@@ -470,54 +455,28 @@ export function createWhatsAppFrontend(
|
|
|
470
455
|
}
|
|
471
456
|
});
|
|
472
457
|
|
|
473
|
-
let pairingRequested = false;
|
|
474
458
|
return new Promise((resolve) => {
|
|
475
459
|
socket.ev.on("connection.update", (update) => {
|
|
476
460
|
const { connection, lastDisconnect, qr } = update;
|
|
477
461
|
|
|
478
462
|
if (qr && !state.creds.registered) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
lastPairingNotifyAt,
|
|
496
|
-
)
|
|
497
|
-
) {
|
|
498
|
-
lastPairingNotifyAt = Date.now();
|
|
499
|
-
void notifyAdmin(
|
|
500
|
-
`📱 WhatsApp needs re-pairing.\n` +
|
|
501
|
-
`Code: ${code}\n` +
|
|
502
|
-
`On the phone with ${settings.pairingNumber}: WhatsApp → ` +
|
|
503
|
-
`Linked devices → Link with phone number.\n` +
|
|
504
|
-
`Valid for a few minutes; if it expires, the next code ` +
|
|
505
|
-
`arrives automatically.`,
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
})
|
|
509
|
-
.catch((err) =>
|
|
510
|
-
logError(
|
|
511
|
-
"whatsapp",
|
|
512
|
-
`Pairing code request failed: ${err instanceof Error ? err.message : err}`,
|
|
513
|
-
),
|
|
514
|
-
);
|
|
515
|
-
} else if (!settings.pairingNumber) {
|
|
516
|
-
log(
|
|
517
|
-
"whatsapp",
|
|
518
|
-
"Scan with WhatsApp → Linked devices → Link a device:",
|
|
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.",
|
|
519
479
|
);
|
|
520
|
-
qrcode.generate(qr, { small: true });
|
|
521
480
|
}
|
|
522
481
|
}
|
|
523
482
|
|
|
@@ -527,12 +486,10 @@ export function createWhatsAppFrontend(
|
|
|
527
486
|
.filter((id): id is string => Boolean(id))
|
|
528
487
|
.map(bareId);
|
|
529
488
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
530
|
-
if (
|
|
531
|
-
void notifyAdmin("✅ WhatsApp
|
|
489
|
+
if (unpairedNotified) {
|
|
490
|
+
void notifyAdmin("✅ WhatsApp linked and connected.");
|
|
532
491
|
}
|
|
533
|
-
|
|
534
|
-
pairingCodesIssued = 0;
|
|
535
|
-
lastPairingNotifyAt = undefined;
|
|
492
|
+
unpairedNotified = false;
|
|
536
493
|
log(
|
|
537
494
|
"whatsapp",
|
|
538
495
|
`Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
|
|
@@ -545,17 +502,81 @@ export function createWhatsAppFrontend(
|
|
|
545
502
|
{ output?: { statusCode?: number } } | undefined
|
|
546
503
|
)?.output?.statusCode;
|
|
547
504
|
if (stopping) return resolve("stop");
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
"
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 registering is a pairing
|
|
531
|
+
// 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
|
+
if (!state.creds.registered) return resolve("unpaired");
|
|
535
|
+
log(
|
|
536
|
+
"whatsapp",
|
|
537
|
+
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
538
|
+
);
|
|
539
|
+
return resolve("reconnect");
|
|
540
|
+
}
|
|
554
541
|
}
|
|
555
542
|
});
|
|
556
543
|
});
|
|
557
544
|
}
|
|
558
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Wait until a manual pairing completes (event from pairing-lock) or
|
|
548
|
+
* registered creds appear on disk, polling slowly. Resolves promptly
|
|
549
|
+
* when the frontend is stopping.
|
|
550
|
+
*/
|
|
551
|
+
async function parkUntilPaired(): Promise<void> {
|
|
552
|
+
while (!stopping) {
|
|
553
|
+
if (!isManualPairingActive()) {
|
|
554
|
+
try {
|
|
555
|
+
const raw = await readFile(
|
|
556
|
+
resolvePath(dirs.whatsappAuth, "creds.json"),
|
|
557
|
+
"utf-8",
|
|
558
|
+
);
|
|
559
|
+
if ((JSON.parse(raw) as { registered?: boolean }).registered) return;
|
|
560
|
+
} catch {
|
|
561
|
+
/* no creds yet — stay parked */
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const paired = await new Promise<boolean>((r) => {
|
|
565
|
+
const off = onPairingComplete(() => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
off();
|
|
568
|
+
r(true);
|
|
569
|
+
});
|
|
570
|
+
const timer = setTimeout(() => {
|
|
571
|
+
off();
|
|
572
|
+
r(false);
|
|
573
|
+
}, 30_000);
|
|
574
|
+
timer.unref?.();
|
|
575
|
+
});
|
|
576
|
+
if (paired) return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
559
580
|
return {
|
|
560
581
|
name: "whatsapp",
|
|
561
582
|
context,
|
|
@@ -584,6 +605,13 @@ export function createWhatsAppFrontend(
|
|
|
584
605
|
|
|
585
606
|
async init() {
|
|
586
607
|
setWhatsAppBotName(config.botDisplayName);
|
|
608
|
+
// On-demand pairing, driven from another frontend's admin command
|
|
609
|
+
// (/whatsapp pair on Telegram) through the core broker.
|
|
610
|
+
registerPairingProvider({
|
|
611
|
+
label: "WhatsApp",
|
|
612
|
+
isLinked: () => Boolean(sock?.user),
|
|
613
|
+
begin: () => beginPairingAttempt(settings.pairingNumber),
|
|
614
|
+
});
|
|
587
615
|
// The in-memory message-id counter restarts at its base every boot,
|
|
588
616
|
// but history persists — seed it past what the table already holds
|
|
589
617
|
// so post-restart messages don't re-issue ids INSERT OR IGNORE then
|
|
@@ -600,7 +628,13 @@ export function createWhatsAppFrontend(
|
|
|
600
628
|
async start() {
|
|
601
629
|
log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
|
|
602
630
|
while (!stopping) {
|
|
603
|
-
|
|
631
|
+
// A manual pairing attempt owns the auth dir: two sockets on one
|
|
632
|
+
// keypair corrupt it and burn rate-limited pairing attempts.
|
|
633
|
+
if (isManualPairingActive()) {
|
|
634
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
let outcome: "reconnect" | "logged-out" | "unpaired" | "stop";
|
|
604
638
|
try {
|
|
605
639
|
outcome = await connectOnce();
|
|
606
640
|
} catch (err) {
|
|
@@ -614,33 +648,29 @@ export function createWhatsAppFrontend(
|
|
|
614
648
|
if (outcome === "stop" || stopping) break;
|
|
615
649
|
if (outcome === "logged-out") {
|
|
616
650
|
// These credentials are dead — WhatsApp unlinked the device.
|
|
617
|
-
// Wipe them
|
|
618
|
-
//
|
|
651
|
+
// Wipe them and PARK. Re-pairing needs a human holding the
|
|
652
|
+
// phone, so it happens strictly on demand (Telegram's
|
|
653
|
+
// `/whatsapp pair`) — every automatic retry policy tried here,
|
|
654
|
+
// including a 30-minute backoff ladder, still burned codes
|
|
655
|
+
// against WhatsApp's rate limit until scans failed with
|
|
656
|
+
// "couldn't connect device" for everyone.
|
|
619
657
|
logWarn(
|
|
620
658
|
"whatsapp",
|
|
621
|
-
"Logged out by WhatsApp —
|
|
659
|
+
"Logged out by WhatsApp — parked until /whatsapp pair re-links",
|
|
660
|
+
);
|
|
661
|
+
void notifyAdmin(
|
|
662
|
+
"⚠️ WhatsApp unlinked this device. When you're ready to " +
|
|
663
|
+
"re-link, send /whatsapp pair and scan the QR I reply with. " +
|
|
664
|
+
"Nothing is retried until then.",
|
|
622
665
|
);
|
|
623
|
-
if (failedPairingCycles === 0 && pairingCodesIssued === 0) {
|
|
624
|
-
void notifyAdmin(
|
|
625
|
-
"⚠️ WhatsApp unlinked this device (logged out). " +
|
|
626
|
-
"Re-pairing — a pairing code follows.",
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
666
|
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (pairingDelay > 0) {
|
|
638
|
-
log(
|
|
639
|
-
"whatsapp",
|
|
640
|
-
`Next pairing attempt in ${Math.round(pairingDelay / 60_000)}m`,
|
|
641
|
-
);
|
|
642
|
-
await new Promise((r) => setTimeout(r, pairingDelay));
|
|
643
|
-
}
|
|
667
|
+
await parkUntilPaired();
|
|
668
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (outcome === "unpaired") {
|
|
672
|
+
log("whatsapp", "Not paired — parked until /whatsapp pair links");
|
|
673
|
+
await parkUntilPaired();
|
|
644
674
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
645
675
|
continue;
|
|
646
676
|
}
|
|
@@ -652,12 +682,17 @@ export function createWhatsAppFrontend(
|
|
|
652
682
|
|
|
653
683
|
async stop() {
|
|
654
684
|
stopping = true;
|
|
685
|
+
registerPairingProvider(null);
|
|
655
686
|
try {
|
|
656
687
|
sock?.end(undefined);
|
|
657
688
|
} catch {
|
|
658
689
|
/* already closed */
|
|
659
690
|
}
|
|
660
691
|
sock = null;
|
|
692
|
+
// Drain queued credential writes before the process exits — a key
|
|
693
|
+
// half-written at shutdown is invisible until the server starts
|
|
694
|
+
// rejecting stanzas with it.
|
|
695
|
+
await flushAuthWrites();
|
|
661
696
|
await gateway.stop();
|
|
662
697
|
log("whatsapp", "WhatsApp frontend stopped");
|
|
663
698
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordination between the WhatsApp frontend's connection loop and a
|
|
3
|
+
* manual pairing attempt (driven from Telegram via the core broker).
|
|
4
|
+
*
|
|
5
|
+
* Only one Baileys socket may own the auth dir at a time: a manual
|
|
6
|
+
* pairing socket racing the frontend's reconnect loop corrupts the
|
|
7
|
+
* half-registered credentials and burns pairing attempts against
|
|
8
|
+
* WhatsApp's rate limits. The loop checks `isManualPairingActive()`
|
|
9
|
+
* before every connect and parks; a completed pairing fires the
|
|
10
|
+
* completion event so a parked loop wakes immediately instead of on its
|
|
11
|
+
* next poll.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let active = false;
|
|
15
|
+
const completionCallbacks = new Set<() => void>();
|
|
16
|
+
|
|
17
|
+
export function acquireManualPairing(): boolean {
|
|
18
|
+
if (active) return false;
|
|
19
|
+
active = true;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function releaseManualPairing(paired: boolean): void {
|
|
24
|
+
active = false;
|
|
25
|
+
if (paired) {
|
|
26
|
+
for (const cb of completionCallbacks) cb();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isManualPairingActive(): boolean {
|
|
31
|
+
return active;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns an unsubscribe function. */
|
|
35
|
+
export function onPairingComplete(cb: () => void): () => void {
|
|
36
|
+
completionCallbacks.add(cb);
|
|
37
|
+
return () => completionCallbacks.delete(cb);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Test seam. */
|
|
41
|
+
export function resetPairingLock(): void {
|
|
42
|
+
active = false;
|
|
43
|
+
completionCallbacks.clear();
|
|
44
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One bounded, on-demand WhatsApp pairing attempt — the provider behind
|
|
3
|
+
* `core/pairing-broker.ts`, invoked from Telegram's /whatsapp command.
|
|
4
|
+
*
|
|
5
|
+
* One command = one attempt: a fresh socket, the FIRST QR (rendered to
|
|
6
|
+
* PNG for delivery as a photo) plus a phone-number code when configured,
|
|
7
|
+
* a single ~2-minute window, then a definite outcome. No retries, no
|
|
8
|
+
* background loops — the human re-runs the command when they're ready.
|
|
9
|
+
* Entering a code / scanning ends with Baileys' 515 restartRequired,
|
|
10
|
+
* which is SUCCESS: one same-creds reconnect completes the login.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { rmSync } from "node:fs";
|
|
14
|
+
import makeWASocket, { type WASocket } from "baileys";
|
|
15
|
+
import QRCode from "qrcode";
|
|
16
|
+
import type {
|
|
17
|
+
PairingAttempt,
|
|
18
|
+
PairingOutcome,
|
|
19
|
+
} from "../../core/pairing-broker.js";
|
|
20
|
+
import { log, logError } from "../../util/log.js";
|
|
21
|
+
import { dirs } from "../../util/paths.js";
|
|
22
|
+
import { useAtomicAuthState, flushAuthWrites } from "./auth-state.js";
|
|
23
|
+
import { classifyClose } from "./pairing.js";
|
|
24
|
+
import { acquireManualPairing, releaseManualPairing } from "./pairing-lock.js";
|
|
25
|
+
import { bareId } from "./identity.js";
|
|
26
|
+
import { makeWaLogger } from "./wa-logger.js";
|
|
27
|
+
|
|
28
|
+
/** How long one attempt may run before reporting "expired". */
|
|
29
|
+
const ATTEMPT_WINDOW_MS = 150_000;
|
|
30
|
+
/** How long we wait for the first QR before calling the attempt failed. */
|
|
31
|
+
const FIRST_QR_TIMEOUT_MS = 20_000;
|
|
32
|
+
|
|
33
|
+
export async function beginPairingAttempt(
|
|
34
|
+
pairingNumber?: string,
|
|
35
|
+
): Promise<PairingAttempt> {
|
|
36
|
+
if (!acquireManualPairing()) {
|
|
37
|
+
throw new Error("A pairing attempt is already in progress");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let sock: WASocket | null = null;
|
|
41
|
+
let settled = false;
|
|
42
|
+
let paired = false;
|
|
43
|
+
let resolveResult!: (o: PairingOutcome) => void;
|
|
44
|
+
const result = new Promise<PairingOutcome>((r) => (resolveResult = r));
|
|
45
|
+
|
|
46
|
+
const settle = (outcome: PairingOutcome) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
paired = outcome.ok;
|
|
50
|
+
try {
|
|
51
|
+
sock?.end(undefined);
|
|
52
|
+
} catch {
|
|
53
|
+
/* closed */
|
|
54
|
+
}
|
|
55
|
+
void flushAuthWrites().finally(() => releaseManualPairing(paired));
|
|
56
|
+
resolveResult(outcome);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const windowTimer = setTimeout(
|
|
60
|
+
() => settle({ ok: false, reason: "expired" }),
|
|
61
|
+
ATTEMPT_WINDOW_MS,
|
|
62
|
+
);
|
|
63
|
+
windowTimer.unref?.();
|
|
64
|
+
|
|
65
|
+
/** One socket generation; 515 recurses once with the same creds. */
|
|
66
|
+
const connect = async (afterPairing: boolean): Promise<void> => {
|
|
67
|
+
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
68
|
+
const socket = makeWASocket({
|
|
69
|
+
auth: state,
|
|
70
|
+
logger: makeWaLogger(),
|
|
71
|
+
markOnlineOnConnect: false,
|
|
72
|
+
qrTimeout: 120_000,
|
|
73
|
+
connectTimeoutMs: 60_000,
|
|
74
|
+
});
|
|
75
|
+
sock = socket;
|
|
76
|
+
socket.ev.on("creds.update", saveCreds);
|
|
77
|
+
socket.ev.on("connection.update", ({ connection, lastDisconnect, qr }) => {
|
|
78
|
+
if (qr && !afterPairing && !firstQr.done) {
|
|
79
|
+
firstQr.done = true;
|
|
80
|
+
firstQr.resolve(qr);
|
|
81
|
+
}
|
|
82
|
+
if (connection === "open") {
|
|
83
|
+
const identity = bareId(socket.user?.id) || "unknown";
|
|
84
|
+
log("whatsapp", `Manual pairing succeeded — linked as ${identity}`);
|
|
85
|
+
settle({ ok: true, identity });
|
|
86
|
+
}
|
|
87
|
+
if (connection === "close") {
|
|
88
|
+
const code = (
|
|
89
|
+
lastDisconnect?.error as
|
|
90
|
+
{ output?: { statusCode?: number } } | undefined
|
|
91
|
+
)?.output?.statusCode;
|
|
92
|
+
const disposition = classifyClose(code, state.creds.registered);
|
|
93
|
+
if (disposition.kind === "pairing-accepted" && !settled) {
|
|
94
|
+
// Scan/code accepted — finish the login on the same creds.
|
|
95
|
+
log("whatsapp", "Pairing accepted (515) — completing login");
|
|
96
|
+
void connect(true).catch((err) =>
|
|
97
|
+
settle({
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: "failed",
|
|
100
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!settled) {
|
|
106
|
+
settle({
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: "expired",
|
|
109
|
+
detail: `connection closed (code ${code ?? "?"})`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const firstQr: {
|
|
118
|
+
done: boolean;
|
|
119
|
+
resolve: (qr: string) => void;
|
|
120
|
+
promise: Promise<string>;
|
|
121
|
+
} = (() => {
|
|
122
|
+
let resolve!: (qr: string) => void;
|
|
123
|
+
const promise = new Promise<string>((r) => (resolve = r));
|
|
124
|
+
return { done: false, resolve, promise };
|
|
125
|
+
})();
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// A half-registered keypair from an earlier abandoned attempt 401s
|
|
129
|
+
// instantly; start clean. Registered creds are never wiped here —
|
|
130
|
+
// begin() isn't offered when the account is already linked.
|
|
131
|
+
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
132
|
+
await connect(false);
|
|
133
|
+
|
|
134
|
+
const qrString = await Promise.race([
|
|
135
|
+
firstQr.promise,
|
|
136
|
+
new Promise<null>((r) => {
|
|
137
|
+
const t = setTimeout(() => r(null), FIRST_QR_TIMEOUT_MS);
|
|
138
|
+
t.unref?.();
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
if (!qrString) {
|
|
142
|
+
settle({ ok: false, reason: "failed", detail: "no QR from server" });
|
|
143
|
+
clearTimeout(windowTimer);
|
|
144
|
+
return {
|
|
145
|
+
result,
|
|
146
|
+
cancel: () => settle({ ok: false, reason: "cancelled" }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const qrPng = new Uint8Array(
|
|
151
|
+
await QRCode.toBuffer(qrString, {
|
|
152
|
+
type: "png",
|
|
153
|
+
errorCorrectionLevel: "M",
|
|
154
|
+
scale: 8,
|
|
155
|
+
margin: 2,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
let code: string | undefined;
|
|
160
|
+
if (pairingNumber) {
|
|
161
|
+
try {
|
|
162
|
+
code = await sock!.requestPairingCode(bareId(pairingNumber));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// The QR still works; the code is a bonus.
|
|
165
|
+
logError(
|
|
166
|
+
"whatsapp",
|
|
167
|
+
`pairing code request failed: ${err instanceof Error ? err.message : err}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
qrPng,
|
|
174
|
+
...(code ? { code } : {}),
|
|
175
|
+
result,
|
|
176
|
+
cancel: () => settle({ ok: false, reason: "cancelled" }),
|
|
177
|
+
};
|
|
178
|
+
} catch (err) {
|
|
179
|
+
settle({
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: "failed",
|
|
182
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
183
|
+
});
|
|
184
|
+
clearTimeout(windowTimer);
|
|
185
|
+
return { result, cancel: () => {} };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,40 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pairing
|
|
2
|
+
* Pairing policy for the WhatsApp frontend.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* invalidating the last, all of them visible only in the daemon log.
|
|
11
|
-
* That cadence is also exactly the shape WhatsApp rate-limits.
|
|
12
|
-
*
|
|
13
|
-
* Policy, kept pure here so it is testable without a socket:
|
|
14
|
-
* - retry pairing on a growing delay (quick first retry, 30-min cap),
|
|
15
|
-
* - surface each fresh code to the admin over a live frontend, but
|
|
16
|
-
* after the first few, at most one notification per hour — each
|
|
17
|
-
* notification always carries the CURRENT code.
|
|
4
|
+
* Pairing is strictly on demand (Telegram's /whatsapp pair → the core
|
|
5
|
+
* pairing broker): the frontend never requests codes by itself. Every
|
|
6
|
+
* automatic retry policy tried here — including a 30-minute backoff
|
|
7
|
+
* ladder — still burned pairing codes against WhatsApp's rate limit
|
|
8
|
+
* until every scan failed with "couldn't connect device". What remains
|
|
9
|
+
* is the close-code taxonomy the connection loop reads.
|
|
18
10
|
*/
|
|
19
11
|
|
|
20
|
-
|
|
21
|
-
export function nextPairingDelayMs(failedAttempts: number): number {
|
|
22
|
-
if (failedAttempts <= 0) return 0;
|
|
23
|
-
const LADDER = [5_000, 5 * 60_000, 10 * 60_000, 20 * 60_000];
|
|
24
|
-
const CAP = 30 * 60_000;
|
|
25
|
-
return LADDER[failedAttempts - 1] ?? CAP;
|
|
26
|
-
}
|
|
12
|
+
// ── Close-code taxonomy ─────────────────────────────────────────────────────
|
|
27
13
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
14
|
+
/**
|
|
15
|
+
* What one Baileys `connection: "close"` means for the reconnect loop.
|
|
16
|
+
* Ported from how OpenClaw's connection controller reads DisconnectReason
|
|
17
|
+
* — the old handler knew only "loggedOut or not", which turned pairing
|
|
18
|
+
* success (515) into an anonymous reconnect and let another client's
|
|
19
|
+
* takeover (440) start a reconnect fight.
|
|
20
|
+
*/
|
|
21
|
+
export type CloseDisposition =
|
|
22
|
+
| { kind: "pairing-accepted" } // 515: code entered — reconnect NOW, same creds
|
|
23
|
+
| { kind: "replaced" } // 440: another socket owns the session — back off hard
|
|
24
|
+
| { kind: "logged-out"; midPairing: boolean } // 401: dead creds
|
|
25
|
+
| { kind: "reconnect" }; // everything else: transient
|
|
31
26
|
|
|
32
|
-
export function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
export function classifyClose(
|
|
28
|
+
statusCode: number | undefined,
|
|
29
|
+
credsRegistered: boolean,
|
|
30
|
+
): CloseDisposition {
|
|
31
|
+
switch (statusCode) {
|
|
32
|
+
case 515: // DisconnectReason.restartRequired — expected right after pairing
|
|
33
|
+
return { kind: "pairing-accepted" };
|
|
34
|
+
case 440: // DisconnectReason.connectionReplaced
|
|
35
|
+
return { kind: "replaced" };
|
|
36
|
+
case 401: // DisconnectReason.loggedOut
|
|
37
|
+
case 403: // DisconnectReason.forbidden — account-level, same recovery
|
|
38
|
+
return { kind: "logged-out", midPairing: !credsRegistered };
|
|
39
|
+
default:
|
|
40
|
+
return { kind: "reconnect" };
|
|
41
|
+
}
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
/** How long to sit out after a 440 — another client owns the session. */
|
|
45
|
+
export const REPLACED_BACKOFF_MS = 60_000;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baileys logs through its own pino instance, far too chatty for a daemon
|
|
3
|
+
* that already has structured logging. Bridge warn+ into Talon's log and
|
|
4
|
+
* drop the rest. The shape is pino's minimal logger contract.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the frontend socket (index.ts) and the manual pairing socket
|
|
7
|
+
* (pairing-service.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logError, logWarn } from "../../util/log.js";
|
|
11
|
+
|
|
12
|
+
export function makeWaLogger(): never {
|
|
13
|
+
const fmt = (args: unknown[]): string =>
|
|
14
|
+
args
|
|
15
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
16
|
+
.join(" ")
|
|
17
|
+
.slice(0, 300);
|
|
18
|
+
const shim = {
|
|
19
|
+
level: "warn",
|
|
20
|
+
child: () => shim,
|
|
21
|
+
trace: () => {},
|
|
22
|
+
debug: () => {},
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: (...args: unknown[]) => logWarn("whatsapp", `baileys: ${fmt(args)}`),
|
|
25
|
+
error: (...args: unknown[]) =>
|
|
26
|
+
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
27
|
+
fatal: (...args: unknown[]) =>
|
|
28
|
+
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
29
|
+
};
|
|
30
|
+
return shim as never;
|
|
31
|
+
}
|
package/src/util/config.ts
CHANGED
|
@@ -253,8 +253,9 @@ const whatsappConfigSchema = z
|
|
|
253
253
|
*/
|
|
254
254
|
respondMode: z.enum(["mention", "all"]).default("mention"),
|
|
255
255
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
256
|
+
* The account's own number in E.164 digits without the plus (e.g.
|
|
257
|
+
* "353871234567"). When set, on-demand pairing (/whatsapp pair)
|
|
258
|
+
* offers a phone-number code alongside the QR.
|
|
258
259
|
*/
|
|
259
260
|
pairingNumber: z.string().optional(),
|
|
260
261
|
/** Mark handled inbound messages as read (blue ticks). */
|