talon-agent 3.26.0 → 3.28.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 +4 -2
- package/prompts/system/heartbeat-agent.md +1 -1
- package/src/core/engine/gateway-actions/cross-send.ts +91 -0
- package/src/core/engine/gateway-actions/index.ts +16 -1
- package/src/core/engine/gateway.ts +4 -0
- package/src/core/pairing-broker.ts +50 -0
- package/src/core/tools/cross-send.ts +46 -0
- package/src/core/tools/index.ts +2 -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/index.ts +25 -2
- package/src/frontend/whatsapp/index.ts +104 -109
- package/src/frontend/whatsapp/pairing-lock.ts +44 -0
- package/src/frontend/whatsapp/pairing-service.ts +187 -0
- package/src/frontend/whatsapp/pairing.ts +7 -37
- package/src/frontend/whatsapp/registry.ts +39 -0
- 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.28.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"chatbot"
|
|
19
19
|
],
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=24"
|
|
21
|
+
"node": ">=24.15"
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "./src/index.ts",
|
|
@@ -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",
|
|
@@ -40,7 +40,7 @@ is a primary responsibility, not an optional extra. Goal tools
|
|
|
40
40
|
(`update_goal`, `list_goals`, …) require an explicit `chat_id` parameter
|
|
41
41
|
in heartbeat mode — use the chat id shown next to each goal.
|
|
42
42
|
|
|
43
|
-
OUTBOUND MESSAGING: You also have access to the frontend tool servers — {{toolList}} — which expose `send`, `react`, and the rest of the messaging surface. Because there is NO ambient chat in heartbeat mode, every outbound tool call MUST include an explicit `chat_id` parameter. The bridge promotes that chat_id to the routing target, so `send(type="text", text="...", chat_id=N)` from `{{exampleFrontend}}-tools` delivers a message to chat N on that frontend. Known chat IDs live in your memory.md (per-frontend — for Telegram, Dylan's DM ID and group IDs are recorded; other frontends list their own). Without `chat_id`, the gateway returns 'No active chat context and no explicit numeric chat_id'.
|
|
43
|
+
OUTBOUND MESSAGING: You also have access to the frontend tool servers — {{toolList}} — which expose `send`, `react`, and the rest of the messaging surface. Because there is NO ambient chat in heartbeat mode, every outbound tool call MUST include an explicit `chat_id` parameter. The bridge promotes that chat_id to the routing target, so `send(type="text", text="...", chat_id=N)` from `{{exampleFrontend}}-tools` delivers a message to chat N on that frontend. Known chat IDs live in your memory.md (per-frontend — for Telegram, Dylan's DM ID and group IDs are recorded; other frontends list their own). Without `chat_id`, the gateway returns 'No active chat context and no explicit numeric chat_id'. Cross-frontend: every tool server also carries `send_via(frontend=..., target=..., text=...)`, which delivers through ANY enabled frontend — target is the numeric chat id as a string, or for WhatsApp a phone number ("+353871234567") or wa_dm_/wa_group_ id.
|
|
44
44
|
|
|
45
45
|
Reaching out is part of the job, not an exception: when a run turns up
|
|
46
46
|
something a user would genuinely want to know — a goal completed or
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-frontend send — the server side of the `send_via` tool.
|
|
3
|
+
*
|
|
4
|
+
* A chat-free shared action: any session (an active Telegram chat, the
|
|
5
|
+
* heartbeat, a dream run) delivers a plain text message through ANY
|
|
6
|
+
* enabled messaging frontend by naming it explicitly. Explicit is the
|
|
7
|
+
* point — the gateway's normal chat_id routing infers the owning
|
|
8
|
+
* frontend from the numeric id's shape, and WhatsApp's hash-derived ids
|
|
9
|
+
* also match the Telegram matcher, so a cross-frontend send routed that
|
|
10
|
+
* way lands on the wrong platform once the originating turn's context
|
|
11
|
+
* is cleared.
|
|
12
|
+
*
|
|
13
|
+
* Core never imports src/frontend (dependency-cruiser enforces it), so
|
|
14
|
+
* dispatch goes through a broker: `Gateway.registerFrontendHandler`
|
|
15
|
+
* mirrors each frontend's action handler here, and the action calls the
|
|
16
|
+
* target's own `send_message` — the one send action every messaging
|
|
17
|
+
* frontend implements. A numeric target doubles as the handler's chatId
|
|
18
|
+
* key, so id-addressed frontends (telegram, discord, teams, native)
|
|
19
|
+
* need no changes; non-numeric forms (WhatsApp phone numbers, wa_* ids)
|
|
20
|
+
* ride in `body.target` for the frontend's adapter to resolve.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { FrontendActionHandler } from "../../types.js";
|
|
24
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const targets = new Map<string, FrontendActionHandler>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Broker registration — called by `Gateway.registerFrontendHandler` as
|
|
30
|
+
* each frontend wires up (and with null on deregistration), so the set
|
|
31
|
+
* of reachable targets is exactly the set of enabled frontends.
|
|
32
|
+
*/
|
|
33
|
+
export function registerCrossSendTarget(
|
|
34
|
+
name: string,
|
|
35
|
+
handler: FrontendActionHandler | null,
|
|
36
|
+
): void {
|
|
37
|
+
if (handler === null) targets.delete(name);
|
|
38
|
+
else targets.set(name, handler);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const crossSendHandlers: SharedActionHandlers = {
|
|
42
|
+
send_via: async (body) => {
|
|
43
|
+
const frontend = String(body.frontend ?? "")
|
|
44
|
+
.trim()
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
const target = String(body.target ?? "").trim();
|
|
47
|
+
const text = String(body.text ?? "");
|
|
48
|
+
if (!frontend) {
|
|
49
|
+
return { ok: false, error: "send_via: frontend is required" };
|
|
50
|
+
}
|
|
51
|
+
if (!target) {
|
|
52
|
+
return { ok: false, error: "send_via: target is required" };
|
|
53
|
+
}
|
|
54
|
+
if (!text.trim()) {
|
|
55
|
+
return { ok: false, error: "send_via: text is required" };
|
|
56
|
+
}
|
|
57
|
+
const handler = targets.get(frontend);
|
|
58
|
+
if (!handler) {
|
|
59
|
+
const enabled = [...targets.keys()].sort().join(", ") || "none";
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `send_via: the ${frontend} frontend is not enabled (enabled: ${enabled})`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// A numeric target is the handler's chatId key; other forms travel in
|
|
66
|
+
// body.target for the frontend's adapter to resolve. 0 is the same
|
|
67
|
+
// "no chat" sentinel the chat-free dispatch itself uses.
|
|
68
|
+
const numericTarget = /^-?\d+$/.test(target) ? Number(target) : 0;
|
|
69
|
+
const result = await handler(
|
|
70
|
+
{ action: "send_message", text, target },
|
|
71
|
+
numericTarget,
|
|
72
|
+
);
|
|
73
|
+
if (!result) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `send_via: the ${frontend} frontend does not implement send_message`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* send_via is chat-free by design: it reads only its own explicit target
|
|
85
|
+
* and the broker, so it stays reachable from heartbeat/background runs —
|
|
86
|
+
* and skipping chat resolution is what keeps the wrong-frontend numeric
|
|
87
|
+
* routing hazard out of the path entirely.
|
|
88
|
+
*/
|
|
89
|
+
export const crossSendChatFreeActions: ReadonlySet<string> = new Set([
|
|
90
|
+
"send_via",
|
|
91
|
+
]);
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* - `plugins` — plugin hot-reload
|
|
18
18
|
* - `models` — model / backend discovery
|
|
19
19
|
* - `mesh` — companion device mesh (presence + location)
|
|
20
|
+
* - `cross-send` — explicit-target sends through any enabled frontend
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import type { ActionResult } from "../../types.js";
|
|
@@ -31,7 +32,11 @@ import { scriptHandlers } from "./scripts.js";
|
|
|
31
32
|
import { skillHandlers } from "./skills.js";
|
|
32
33
|
import { pluginHandlers } from "./plugins.js";
|
|
33
34
|
import { modelHandlers } from "./models.js";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
meshHandlers,
|
|
37
|
+
chatFreeActions as meshChatFreeActions,
|
|
38
|
+
} from "./mesh.js";
|
|
39
|
+
import { crossSendHandlers, crossSendChatFreeActions } from "./cross-send.js";
|
|
35
40
|
import { nativeHandlers } from "./native.js";
|
|
36
41
|
|
|
37
42
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
@@ -48,9 +53,19 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
48
53
|
...pluginHandlers,
|
|
49
54
|
...modelHandlers,
|
|
50
55
|
...meshHandlers,
|
|
56
|
+
...crossSendHandlers,
|
|
51
57
|
...nativeHandlers,
|
|
52
58
|
});
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* All chat-free actions — each domain module declares its own set, merged
|
|
62
|
+
* here into the one view the gateway (and `isChatFreeAction`) consults.
|
|
63
|
+
*/
|
|
64
|
+
const chatFreeActions: ReadonlySet<string> = new Set([
|
|
65
|
+
...meshChatFreeActions,
|
|
66
|
+
...crossSendChatFreeActions,
|
|
67
|
+
]);
|
|
68
|
+
|
|
54
69
|
export async function handleSharedAction(
|
|
55
70
|
body: Record<string, unknown>,
|
|
56
71
|
chatId: number,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
handleChatFreeAction,
|
|
26
26
|
isChatFreeAction,
|
|
27
27
|
} from "./gateway-actions/index.js";
|
|
28
|
+
import { registerCrossSendTarget } from "./gateway-actions/cross-send.js";
|
|
28
29
|
import {
|
|
29
30
|
handleHubRequest,
|
|
30
31
|
getHubSessionCount,
|
|
@@ -154,6 +155,9 @@ export class Gateway {
|
|
|
154
155
|
name: string,
|
|
155
156
|
handler: FrontendActionHandler | null,
|
|
156
157
|
): void {
|
|
158
|
+
// Mirror into the cross-send broker so the chat-free `send_via`
|
|
159
|
+
// action can dispatch to any enabled frontend by explicit name.
|
|
160
|
+
registerCrossSendTarget(name, handler);
|
|
157
161
|
if (handler === null) {
|
|
158
162
|
this.frontendHandlers.delete(name);
|
|
159
163
|
return;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-frontend send. Deliberately NOT frontend-restricted (the meshTools
|
|
6
|
+
* pattern): the whole point is reaching a DIFFERENT enabled frontend than
|
|
7
|
+
* the one the session lives on — a Telegram chat messaging a WhatsApp
|
|
8
|
+
* number, the heartbeat reaching WhatsApp when only telegram-tools is
|
|
9
|
+
* mounted. The gateway serves it as a chat-free action (see
|
|
10
|
+
* engine/gateway-actions/cross-send.ts), so it works from background runs
|
|
11
|
+
* with no ambient chat, and the target frontend is named EXPLICITLY —
|
|
12
|
+
* never inferred from the shape of a numeric id.
|
|
13
|
+
*/
|
|
14
|
+
export const crossSendTools: ToolDefinition[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "send_via",
|
|
17
|
+
description: `Send a plain text message through ANY enabled messaging frontend — including one this chat does not live on. Use it to reach someone on another platform (e.g. from Telegram or a heartbeat run, message a WhatsApp number). For the current chat, keep using the normal delivery tools (end_turn / send / send_message).
|
|
18
|
+
|
|
19
|
+
Target forms:
|
|
20
|
+
- whatsapp: a phone number with country code ("+353871234567" or "353871234567"), a wa_dm_<number>/wa_group_<id> chat id, or a raw JID; a bare numeric chat id works only for chats already seen since startup
|
|
21
|
+
- telegram / discord / teams / native: the numeric chat id, as a string (Telegram supergroups/channels are negative, DMs positive)
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
send_via(frontend="whatsapp", target="+353871234567", text="On my way")
|
|
25
|
+
send_via(frontend="telegram", target="-1001426819337", text="Build is green")
|
|
26
|
+
|
|
27
|
+
Fails with a clear error when the named frontend is not enabled or not connected.`,
|
|
28
|
+
schema: {
|
|
29
|
+
frontend: z
|
|
30
|
+
.enum(["telegram", "whatsapp", "discord", "teams", "native"])
|
|
31
|
+
.describe("Messaging frontend to deliver through."),
|
|
32
|
+
target: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe(
|
|
35
|
+
"Destination chat: numeric chat id as a string; for WhatsApp also a phone number with country code or a wa_dm_/wa_group_ id.",
|
|
36
|
+
),
|
|
37
|
+
text: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe(
|
|
40
|
+
"Message text. Markdown supported where the platform supports it.",
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
execute: (params, bridge) => bridge("send_via", params),
|
|
44
|
+
tag: "messaging",
|
|
45
|
+
},
|
|
46
|
+
];
|
package/src/core/tools/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { webTools } from "./web.js";
|
|
|
22
22
|
import { adminTools } from "./admin.js";
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
24
|
import { meshTools } from "./mesh.js";
|
|
25
|
+
import { crossSendTools } from "./cross-send.js";
|
|
25
26
|
import { moderationTools } from "./moderation.js";
|
|
26
27
|
import { nativeTools } from "./native.js";
|
|
27
28
|
|
|
@@ -42,6 +43,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
42
43
|
...adminTools,
|
|
43
44
|
...modelTools,
|
|
44
45
|
...meshTools,
|
|
46
|
+
...crossSendTools,
|
|
45
47
|
...moderationTools,
|
|
46
48
|
];
|
|
47
49
|
|
|
@@ -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
|
+
}
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
import type { WASocket } from "baileys";
|
|
20
20
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
21
21
|
import type { ActionResult } from "../../../core/types.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
lookupWhatsAppChat,
|
|
24
|
+
resolveWhatsAppTarget,
|
|
25
|
+
type WhatsAppChatInfo,
|
|
26
|
+
} from "../registry.js";
|
|
23
27
|
import { chatInfoHandlers } from "./chat-info.js";
|
|
24
28
|
import { mediaHandlers } from "./media.js";
|
|
25
29
|
import { messagingHandlers } from "./messaging.js";
|
|
@@ -57,7 +61,26 @@ export function createWhatsAppActionHandler(
|
|
|
57
61
|
return { ok: false, error: "WhatsApp socket is not connected" };
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
|
|
64
|
+
// An explicit cross-send target (send_via) names the destination
|
|
65
|
+
// itself — phone number, wa_* id, or JID — and may be a chat this
|
|
66
|
+
// process has never seen; resolveWhatsAppTarget registers it so the
|
|
67
|
+
// usual numeric-id routing works from then on. Without a target,
|
|
68
|
+
// the gateway's numeric chatId (an active chat) is the destination.
|
|
69
|
+
let chat: WhatsAppChatInfo | null;
|
|
70
|
+
if (body.target !== undefined) {
|
|
71
|
+
chat = resolveWhatsAppTarget(body.target) ?? null;
|
|
72
|
+
if (!chat) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error:
|
|
76
|
+
`Unresolvable WhatsApp target "${String(body.target)}" — use a ` +
|
|
77
|
+
`phone number with country code, a wa_dm_/wa_group_ id, or a ` +
|
|
78
|
+
`known chat id.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
chat = lookupWhatsAppChat(chatId) ?? null;
|
|
83
|
+
}
|
|
61
84
|
if (!chat && !CHATLESS_ACTIONS.has(action)) {
|
|
62
85
|
return {
|
|
63
86
|
ok: false,
|
|
@@ -15,6 +15,8 @@
|
|
|
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
21
|
isJidGroup,
|
|
20
22
|
type WAMessage,
|
|
@@ -51,14 +53,13 @@ import {
|
|
|
51
53
|
seedMessageStore,
|
|
52
54
|
} from "./message-store.js";
|
|
53
55
|
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
54
|
-
import {
|
|
55
|
-
|
|
56
|
-
nextPairingDelayMs,
|
|
57
|
-
REPLACED_BACKOFF_MS,
|
|
58
|
-
shouldNotifyPairingCode,
|
|
59
|
-
} from "./pairing.js";
|
|
56
|
+
import { classifyClose, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
57
|
+
import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
|
|
60
58
|
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
59
|
+
import { makeWaLogger } from "./wa-logger.js";
|
|
61
60
|
import { notifyAdmin } from "../../core/notify.js";
|
|
61
|
+
import { registerPairingProvider } from "../../core/pairing-broker.js";
|
|
62
|
+
import { beginPairingAttempt } from "./pairing-service.js";
|
|
62
63
|
import {
|
|
63
64
|
lookupWhatsAppChat,
|
|
64
65
|
registerWhatsAppChat,
|
|
@@ -113,32 +114,6 @@ function extractText(msg: WAMessage): string {
|
|
|
113
114
|
);
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
/**
|
|
117
|
-
* Baileys logs through its own pino instance, far too chatty for a daemon
|
|
118
|
-
* that already has structured logging. Bridge warn+ into Talon's log and
|
|
119
|
-
* drop the rest. The shape is pino's minimal logger contract.
|
|
120
|
-
*/
|
|
121
|
-
function makeWaLogger(): never {
|
|
122
|
-
const fmt = (args: unknown[]): string =>
|
|
123
|
-
args
|
|
124
|
-
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
125
|
-
.join(" ")
|
|
126
|
-
.slice(0, 300);
|
|
127
|
-
const shim = {
|
|
128
|
-
level: "warn",
|
|
129
|
-
child: () => shim,
|
|
130
|
-
trace: () => {},
|
|
131
|
-
debug: () => {},
|
|
132
|
-
info: () => {},
|
|
133
|
-
warn: (...args: unknown[]) => logWarn("whatsapp", `baileys: ${fmt(args)}`),
|
|
134
|
-
error: (...args: unknown[]) =>
|
|
135
|
-
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
136
|
-
fatal: (...args: unknown[]) =>
|
|
137
|
-
logError("whatsapp", `baileys: ${fmt(args)}`),
|
|
138
|
-
};
|
|
139
|
-
return shim as never;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
117
|
// ── Frontend factory ─────────────────────────────────────────────────────────
|
|
143
118
|
|
|
144
119
|
export function createWhatsAppFrontend(
|
|
@@ -160,11 +135,8 @@ export function createWhatsAppFrontend(
|
|
|
160
135
|
let sock: WASocket | null = null;
|
|
161
136
|
let stopping = false;
|
|
162
137
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
163
|
-
/**
|
|
164
|
-
let
|
|
165
|
-
/** Codes issued this outage — throttles the admin notifications. */
|
|
166
|
-
let pairingCodesIssued = 0;
|
|
167
|
-
let lastPairingNotifyAt: number | undefined;
|
|
138
|
+
/** One "not linked" admin note per outage, not one per QR window. */
|
|
139
|
+
let unpairedNotified = false;
|
|
168
140
|
/** Our own ids (phone and LID), once connected — for mention detection. */
|
|
169
141
|
let selfIds: string[] = [];
|
|
170
142
|
|
|
@@ -436,7 +408,9 @@ export function createWhatsAppFrontend(
|
|
|
436
408
|
}
|
|
437
409
|
|
|
438
410
|
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
439
|
-
async function connectOnce(): Promise<
|
|
411
|
+
async function connectOnce(): Promise<
|
|
412
|
+
"reconnect" | "logged-out" | "unpaired" | "stop"
|
|
413
|
+
> {
|
|
440
414
|
// Atomic replacement for Baileys' useMultiFileAuthState — same disk
|
|
441
415
|
// format, torn-write-proof (see auth-state.ts for why that matters).
|
|
442
416
|
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
@@ -481,54 +455,28 @@ export function createWhatsAppFrontend(
|
|
|
481
455
|
}
|
|
482
456
|
});
|
|
483
457
|
|
|
484
|
-
let pairingRequested = false;
|
|
485
458
|
return new Promise((resolve) => {
|
|
486
459
|
socket.ev.on("connection.update", (update) => {
|
|
487
460
|
const { connection, lastDisconnect, qr } = update;
|
|
488
461
|
|
|
489
462
|
if (qr && !state.creds.registered) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
lastPairingNotifyAt,
|
|
507
|
-
)
|
|
508
|
-
) {
|
|
509
|
-
lastPairingNotifyAt = Date.now();
|
|
510
|
-
void notifyAdmin(
|
|
511
|
-
`📱 WhatsApp needs re-pairing.\n` +
|
|
512
|
-
`Code: ${code}\n` +
|
|
513
|
-
`On the phone with ${settings.pairingNumber}: WhatsApp → ` +
|
|
514
|
-
`Linked devices → Link with phone number.\n` +
|
|
515
|
-
`Valid for a few minutes; if it expires, the next code ` +
|
|
516
|
-
`arrives automatically.`,
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
})
|
|
520
|
-
.catch((err) =>
|
|
521
|
-
logError(
|
|
522
|
-
"whatsapp",
|
|
523
|
-
`Pairing code request failed: ${err instanceof Error ? err.message : err}`,
|
|
524
|
-
),
|
|
525
|
-
);
|
|
526
|
-
} else if (!settings.pairingNumber) {
|
|
527
|
-
log(
|
|
528
|
-
"whatsapp",
|
|
529
|
-
"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.",
|
|
530
479
|
);
|
|
531
|
-
qrcode.generate(qr, { small: true });
|
|
532
480
|
}
|
|
533
481
|
}
|
|
534
482
|
|
|
@@ -538,12 +486,10 @@ export function createWhatsAppFrontend(
|
|
|
538
486
|
.filter((id): id is string => Boolean(id))
|
|
539
487
|
.map(bareId);
|
|
540
488
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
541
|
-
if (
|
|
542
|
-
void notifyAdmin("✅ WhatsApp
|
|
489
|
+
if (unpairedNotified) {
|
|
490
|
+
void notifyAdmin("✅ WhatsApp linked and connected.");
|
|
543
491
|
}
|
|
544
|
-
|
|
545
|
-
pairingCodesIssued = 0;
|
|
546
|
-
lastPairingNotifyAt = undefined;
|
|
492
|
+
unpairedNotified = false;
|
|
547
493
|
log(
|
|
548
494
|
"whatsapp",
|
|
549
495
|
`Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
|
|
@@ -581,6 +527,11 @@ export function createWhatsAppFrontend(
|
|
|
581
527
|
case "logged-out":
|
|
582
528
|
return resolve("logged-out");
|
|
583
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");
|
|
584
535
|
log(
|
|
585
536
|
"whatsapp",
|
|
586
537
|
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
@@ -592,6 +543,40 @@ export function createWhatsAppFrontend(
|
|
|
592
543
|
});
|
|
593
544
|
}
|
|
594
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
|
+
|
|
595
580
|
return {
|
|
596
581
|
name: "whatsapp",
|
|
597
582
|
context,
|
|
@@ -620,6 +605,13 @@ export function createWhatsAppFrontend(
|
|
|
620
605
|
|
|
621
606
|
async init() {
|
|
622
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
|
+
});
|
|
623
615
|
// The in-memory message-id counter restarts at its base every boot,
|
|
624
616
|
// but history persists — seed it past what the table already holds
|
|
625
617
|
// so post-restart messages don't re-issue ids INSERT OR IGNORE then
|
|
@@ -636,7 +628,13 @@ export function createWhatsAppFrontend(
|
|
|
636
628
|
async start() {
|
|
637
629
|
log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
|
|
638
630
|
while (!stopping) {
|
|
639
|
-
|
|
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";
|
|
640
638
|
try {
|
|
641
639
|
outcome = await connectOnce();
|
|
642
640
|
} catch (err) {
|
|
@@ -650,33 +648,29 @@ export function createWhatsAppFrontend(
|
|
|
650
648
|
if (outcome === "stop" || stopping) break;
|
|
651
649
|
if (outcome === "logged-out") {
|
|
652
650
|
// These credentials are dead — WhatsApp unlinked the device.
|
|
653
|
-
// Wipe them
|
|
654
|
-
//
|
|
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.
|
|
655
657
|
logWarn(
|
|
656
658
|
"whatsapp",
|
|
657
|
-
"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.",
|
|
658
665
|
);
|
|
659
|
-
if (failedPairingCycles === 0 && pairingCodesIssued === 0) {
|
|
660
|
-
void notifyAdmin(
|
|
661
|
-
"⚠️ WhatsApp unlinked this device (logged out). " +
|
|
662
|
-
"Re-pairing — a pairing code follows.",
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
666
|
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (pairingDelay > 0) {
|
|
674
|
-
log(
|
|
675
|
-
"whatsapp",
|
|
676
|
-
`Next pairing attempt in ${Math.round(pairingDelay / 60_000)}m`,
|
|
677
|
-
);
|
|
678
|
-
await new Promise((r) => setTimeout(r, pairingDelay));
|
|
679
|
-
}
|
|
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();
|
|
680
674
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
681
675
|
continue;
|
|
682
676
|
}
|
|
@@ -688,6 +682,7 @@ export function createWhatsAppFrontend(
|
|
|
688
682
|
|
|
689
683
|
async stop() {
|
|
690
684
|
stopping = true;
|
|
685
|
+
registerPairingProvider(null);
|
|
691
686
|
try {
|
|
692
687
|
sock?.end(undefined);
|
|
693
688
|
} catch {
|
|
@@ -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,44 +1,14 @@
|
|
|
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
|
-
/** Delay before pairing attempt `attempt` (1-based, i.e. after `attempt` failures). */
|
|
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
|
-
}
|
|
27
|
-
|
|
28
|
-
/** First codes always notify; afterwards at most one per hour. */
|
|
29
|
-
export const PAIRING_NOTIFY_FREE_CODES = 3;
|
|
30
|
-
export const PAIRING_NOTIFY_MIN_GAP_MS = 60 * 60_000;
|
|
31
|
-
|
|
32
|
-
export function shouldNotifyPairingCode(
|
|
33
|
-
codesIssued: number,
|
|
34
|
-
lastNotifiedAt: number | undefined,
|
|
35
|
-
now: number = Date.now(),
|
|
36
|
-
): boolean {
|
|
37
|
-
if (codesIssued <= PAIRING_NOTIFY_FREE_CODES) return true;
|
|
38
|
-
if (lastNotifiedAt === undefined) return true;
|
|
39
|
-
return now - lastNotifiedAt >= PAIRING_NOTIFY_MIN_GAP_MS;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
12
|
// ── Close-code taxonomy ─────────────────────────────────────────────────────
|
|
43
13
|
|
|
44
14
|
/**
|
|
@@ -85,6 +85,45 @@ export function lookupWhatsAppChatByString(
|
|
|
85
85
|
return byString.get(chatId);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Resolve an explicit send target — the `target` a cross-frontend
|
|
90
|
+
* `send_via` names — onto chat info, REGISTERING the chat when it's new
|
|
91
|
+
* so numeric-id routing and replies work from then on. Accepted forms:
|
|
92
|
+
*
|
|
93
|
+
* - a registered Talon id: `wa_dm_*`/`wa_group_*` string, or its
|
|
94
|
+
* numeric hash (only resolvable while registered — the hash can't
|
|
95
|
+
* be reversed)
|
|
96
|
+
* - a raw JID: `<number>@s.whatsapp.net` or `<id>@g.us`
|
|
97
|
+
* - a phone number with country code: "+353871234567", "353 87 123 4567"
|
|
98
|
+
*
|
|
99
|
+
* Digits with punctuation normalize to a DM JID; an all-digit string is
|
|
100
|
+
* tried as a registered numeric id first, then treated as a phone number.
|
|
101
|
+
* Returns undefined when nothing sendable can be derived.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveWhatsAppTarget(
|
|
104
|
+
raw: unknown,
|
|
105
|
+
): WhatsAppChatInfo | undefined {
|
|
106
|
+
const target = String(raw ?? "").trim();
|
|
107
|
+
if (!target) return undefined;
|
|
108
|
+
const registered = byString.get(target);
|
|
109
|
+
if (registered) return registered;
|
|
110
|
+
if (/^\d+$/.test(target)) {
|
|
111
|
+
const byHash = byNumeric.get(Number(target));
|
|
112
|
+
if (byHash) return byHash;
|
|
113
|
+
}
|
|
114
|
+
if (target.includes("@")) return registerWhatsAppChat(target);
|
|
115
|
+
if (target.startsWith("wa_group_")) {
|
|
116
|
+
return registerWhatsAppChat(`${target.slice("wa_group_".length)}@g.us`);
|
|
117
|
+
}
|
|
118
|
+
if (target.startsWith("wa_dm_")) {
|
|
119
|
+
return registerWhatsAppChat(
|
|
120
|
+
`${target.slice("wa_dm_".length)}@s.whatsapp.net`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const digits = target.replace(/[^0-9]/g, "");
|
|
124
|
+
return digits ? registerWhatsAppChat(`${digits}@s.whatsapp.net`) : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
88
127
|
/** Test seam: forget every registered chat. */
|
|
89
128
|
export function resetWhatsAppRegistry(): void {
|
|
90
129
|
byNumeric.clear();
|
|
@@ -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). */
|