@openclaw/whatsapp 2026.5.1-beta.1
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/action-runtime-api.ts +1 -0
- package/action-runtime.runtime.ts +1 -0
- package/api.ts +213 -0
- package/auth-presence.ts +80 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +3 -0
- package/config-api.ts +4 -0
- package/constants.ts +1 -0
- package/contract-api.ts +29 -0
- package/directory-contract-api.ts +4 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/doctor-contract-api.ts +8 -0
- package/index.test.ts +13 -0
- package/index.ts +16 -0
- package/legacy-session-surface-api.ts +6 -0
- package/legacy-state-migrations-api.ts +1 -0
- package/light-runtime-api.ts +12 -0
- package/login-qr-api.ts +1 -0
- package/login-qr-runtime.ts +23 -0
- package/openclaw.plugin.json +742 -0
- package/outbound-payload-test-api.ts +1 -0
- package/package.json +74 -0
- package/runtime-api.ts +86 -0
- package/secret-contract-api.ts +4 -0
- package/security-contract-api.ts +4 -0
- package/setup-entry.test.ts +21 -0
- package/setup-entry.ts +21 -0
- package/setup-plugin-api.ts +3 -0
- package/src/account-config.ts +77 -0
- package/src/account-ids.ts +13 -0
- package/src/account-types.ts +5 -0
- package/src/accounts.test.ts +182 -0
- package/src/accounts.ts +176 -0
- package/src/accounts.whatsapp-auth.test.ts +59 -0
- package/src/action-runtime-target-auth.ts +27 -0
- package/src/action-runtime.test.ts +330 -0
- package/src/action-runtime.ts +76 -0
- package/src/active-listener.test.ts +65 -0
- package/src/active-listener.ts +17 -0
- package/src/agent-tools-login.test.ts +81 -0
- package/src/agent-tools-login.ts +113 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +27 -0
- package/src/auth-store.runtime.ts +1 -0
- package/src/auth-store.test.ts +311 -0
- package/src/auth-store.ts +502 -0
- package/src/auto-reply/config.runtime.ts +16 -0
- package/src/auto-reply/constants.ts +1 -0
- package/src/auto-reply/deliver-reply.test.ts +843 -0
- package/src/auto-reply/deliver-reply.ts +279 -0
- package/src/auto-reply/heartbeat-runner.runtime.ts +33 -0
- package/src/auto-reply/heartbeat-runner.test.ts +214 -0
- package/src/auto-reply/heartbeat-runner.ts +330 -0
- package/src/auto-reply/loggers.ts +6 -0
- package/src/auto-reply/mentions.ts +131 -0
- package/src/auto-reply/monitor/ack-reaction.test.ts +170 -0
- package/src/auto-reply/monitor/ack-reaction.ts +99 -0
- package/src/auto-reply/monitor/audio-preflight.runtime.ts +9 -0
- package/src/auto-reply/monitor/broadcast.ts +153 -0
- package/src/auto-reply/monitor/commands.ts +19 -0
- package/src/auto-reply/monitor/echo.ts +64 -0
- package/src/auto-reply/monitor/group-activation.runtime.ts +1 -0
- package/src/auto-reply/monitor/group-activation.test.ts +189 -0
- package/src/auto-reply/monitor/group-activation.ts +73 -0
- package/src/auto-reply/monitor/group-gating.audio-preflight.test.ts +103 -0
- package/src/auto-reply/monitor/group-gating.runtime.ts +8 -0
- package/src/auto-reply/monitor/group-gating.ts +217 -0
- package/src/auto-reply/monitor/group-members.test.ts +56 -0
- package/src/auto-reply/monitor/group-members.ts +65 -0
- package/src/auto-reply/monitor/inbound-context.test.ts +97 -0
- package/src/auto-reply/monitor/inbound-context.ts +92 -0
- package/src/auto-reply/monitor/inbound-dispatch.runtime.ts +21 -0
- package/src/auto-reply/monitor/inbound-dispatch.test.ts +895 -0
- package/src/auto-reply/monitor/inbound-dispatch.ts +422 -0
- package/src/auto-reply/monitor/last-route.test.ts +37 -0
- package/src/auto-reply/monitor/last-route.ts +61 -0
- package/src/auto-reply/monitor/message-line.runtime.ts +38 -0
- package/src/auto-reply/monitor/message-line.ts +54 -0
- package/src/auto-reply/monitor/on-message.audio-preflight.test.ts +354 -0
- package/src/auto-reply/monitor/on-message.ts +291 -0
- package/src/auto-reply/monitor/peer.ts +17 -0
- package/src/auto-reply/monitor/process-message.audio-preflight.test.ts +420 -0
- package/src/auto-reply/monitor/process-message.test.ts +355 -0
- package/src/auto-reply/monitor/process-message.ts +531 -0
- package/src/auto-reply/monitor/runtime-api.ts +30 -0
- package/src/auto-reply/monitor-state.test.ts +64 -0
- package/src/auto-reply/monitor-state.ts +106 -0
- package/src/auto-reply/monitor.ts +600 -0
- package/src/auto-reply/reply-resolver.runtime.ts +1 -0
- package/src/auto-reply/session-snapshot.ts +69 -0
- package/src/auto-reply/types.ts +48 -0
- package/src/auto-reply/util.ts +63 -0
- package/src/auto-reply/web-auto-reply-monitor.test.ts +687 -0
- package/src/auto-reply/web-auto-reply-utils.test.ts +327 -0
- package/src/auto-reply.broadcast-groups.combined.test.ts +246 -0
- package/src/auto-reply.broadcast-groups.test-harness.ts +47 -0
- package/src/auto-reply.impl.ts +7 -0
- package/src/auto-reply.test-harness.ts +421 -0
- package/src/auto-reply.ts +1 -0
- package/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +427 -0
- package/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +908 -0
- package/src/auto-reply.web-auto-reply.last-route.test.ts +184 -0
- package/src/channel-actions.runtime.ts +7 -0
- package/src/channel-actions.test.ts +216 -0
- package/src/channel-actions.ts +84 -0
- package/src/channel-outbound.test.ts +131 -0
- package/src/channel-outbound.ts +36 -0
- package/src/channel-react-action.runtime.ts +7 -0
- package/src/channel-react-action.test.ts +294 -0
- package/src/channel-react-action.ts +84 -0
- package/src/channel.runtime.ts +117 -0
- package/src/channel.setup.test.ts +461 -0
- package/src/channel.setup.ts +28 -0
- package/src/channel.ts +336 -0
- package/src/command-policy.ts +7 -0
- package/src/config-accessors.test.ts +34 -0
- package/src/config-accessors.ts +22 -0
- package/src/config-schema.test.ts +121 -0
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +24 -0
- package/src/connection-controller-registry.test.ts +27 -0
- package/src/connection-controller-registry.ts +49 -0
- package/src/connection-controller.test.ts +282 -0
- package/src/connection-controller.ts +679 -0
- package/src/creds-files.ts +19 -0
- package/src/creds-persistence.ts +102 -0
- package/src/directory-config.test.ts +60 -0
- package/src/directory-config.ts +40 -0
- package/src/directory-contract.test.ts +56 -0
- package/src/doctor-contract.ts +11 -0
- package/src/doctor.test.ts +74 -0
- package/src/doctor.ts +56 -0
- package/src/group-intro.ts +15 -0
- package/src/group-policy.test.ts +36 -0
- package/src/group-policy.ts +40 -0
- package/src/group-session-contract.ts +20 -0
- package/src/group-session-key.test.ts +53 -0
- package/src/group-session-key.ts +41 -0
- package/src/heartbeat-recipients.runtime.ts +6 -0
- package/src/heartbeat-recipients.test.ts +203 -0
- package/src/heartbeat-recipients.ts +104 -0
- package/src/heartbeat.ts +34 -0
- package/src/identity.ts +164 -0
- package/src/inbound/access-control.test-harness.ts +37 -0
- package/src/inbound/access-control.test.ts +375 -0
- package/src/inbound/access-control.ts +237 -0
- package/src/inbound/dedupe.ts +132 -0
- package/src/inbound/extract.test.ts +282 -0
- package/src/inbound/extract.ts +488 -0
- package/src/inbound/lifecycle.ts +39 -0
- package/src/inbound/media.node.test.ts +83 -0
- package/src/inbound/media.ts +102 -0
- package/src/inbound/monitor.ts +890 -0
- package/src/inbound/runtime-api.ts +7 -0
- package/src/inbound/save-media.runtime.ts +1 -0
- package/src/inbound/send-api.test.ts +307 -0
- package/src/inbound/send-api.ts +141 -0
- package/src/inbound/send-result.ts +61 -0
- package/src/inbound/types.ts +106 -0
- package/src/inbound-context.contract.test.ts +32 -0
- package/src/inbound-policy.ts +231 -0
- package/src/inbound.media.test.ts +320 -0
- package/src/inbound.test.ts +290 -0
- package/src/inbound.ts +9 -0
- package/src/login-qr.test.ts +483 -0
- package/src/login-qr.ts +542 -0
- package/src/login.coverage.test.ts +133 -0
- package/src/login.test.ts +89 -0
- package/src/login.ts +70 -0
- package/src/logout.test.ts +162 -0
- package/src/media.test.ts +480 -0
- package/src/media.ts +10 -0
- package/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +400 -0
- package/src/monitor-inbox.append-upsert.test-support.ts +133 -0
- package/src/monitor-inbox.behavior.test.ts +5 -0
- package/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +362 -0
- package/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +306 -0
- package/src/monitor-inbox.streams-inbound-messages.test-support.ts +729 -0
- package/src/monitor-inbox.test-harness.ts +304 -0
- package/src/normalize-target.ts +112 -0
- package/src/normalize.ts +6 -0
- package/src/outbound-adapter.poll.test.ts +62 -0
- package/src/outbound-adapter.sendpayload.test.ts +195 -0
- package/src/outbound-adapter.ts +36 -0
- package/src/outbound-base.test.ts +560 -0
- package/src/outbound-base.ts +244 -0
- package/src/outbound-media-contract.ts +278 -0
- package/src/outbound-media.runtime.ts +36 -0
- package/src/outbound-payload.contract.test.ts +61 -0
- package/src/outbound-send-deps.ts +1 -0
- package/src/outbound-test-support.ts +16 -0
- package/src/pairing-security.test-harness.ts +59 -0
- package/src/qr-image.ts +1 -0
- package/src/qr-terminal.ts +1 -0
- package/src/quoted-message.test.ts +83 -0
- package/src/quoted-message.ts +184 -0
- package/src/reaction-level.test.ts +94 -0
- package/src/reaction-level.ts +21 -0
- package/src/reconnect.test.ts +51 -0
- package/src/reconnect.ts +55 -0
- package/src/resolve-outbound-target.test.ts +265 -0
- package/src/resolve-outbound-target.ts +54 -0
- package/src/resolve-target.test.ts +97 -0
- package/src/runtime-api.ts +60 -0
- package/src/runtime-group-policy.ts +16 -0
- package/src/runtime.ts +9 -0
- package/src/security-contract.ts +47 -0
- package/src/security-fix.ts +71 -0
- package/src/send.test.ts +515 -0
- package/src/send.ts +287 -0
- package/src/session-contract.test.ts +31 -0
- package/src/session-contract.ts +43 -0
- package/src/session-errors.ts +125 -0
- package/src/session-route.ts +26 -0
- package/src/session.runtime.ts +8 -0
- package/src/session.test.ts +559 -0
- package/src/session.ts +330 -0
- package/src/setup-core.ts +52 -0
- package/src/setup-finalize.ts +446 -0
- package/src/setup-surface.test.ts +367 -0
- package/src/setup-surface.ts +69 -0
- package/src/setup-test-helpers.ts +216 -0
- package/src/shared.ts +291 -0
- package/src/socket-timing.test.ts +49 -0
- package/src/socket-timing.ts +38 -0
- package/src/state-migrations.ts +54 -0
- package/src/status-issues.test.ts +137 -0
- package/src/status-issues.ts +185 -0
- package/src/system-prompt.test.ts +199 -0
- package/src/system-prompt.ts +31 -0
- package/src/targets-runtime.ts +180 -0
- package/src/test-helpers.ts +690 -0
- package/src/text-runtime.test.ts +162 -0
- package/src/text-runtime.ts +11 -0
- package/src/vcard.ts +84 -0
- package/targets.ts +5 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
|
|
4
|
+
|
|
5
|
+
const QR_DATA_URL_MAX_LENGTH = 16_384;
|
|
6
|
+
|
|
7
|
+
function readOptionalString(value: unknown): string | undefined {
|
|
8
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createWhatsAppLoginTool(): ChannelAgentTool {
|
|
12
|
+
return {
|
|
13
|
+
label: "WhatsApp Login",
|
|
14
|
+
name: "whatsapp_login",
|
|
15
|
+
ownerOnly: true,
|
|
16
|
+
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
|
|
17
|
+
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
|
|
18
|
+
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
action: Type.Unsafe<"start" | "wait">({
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: ["start", "wait"],
|
|
23
|
+
}),
|
|
24
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
25
|
+
force: Type.Optional(Type.Boolean()),
|
|
26
|
+
accountId: Type.Optional(Type.String()),
|
|
27
|
+
currentQrDataUrl: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
maxLength: QR_DATA_URL_MAX_LENGTH,
|
|
30
|
+
pattern: "^data:image/png;base64,",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
34
|
+
execute: async (_toolCallId, args) => {
|
|
35
|
+
const renderQrReply = (params: {
|
|
36
|
+
message: string;
|
|
37
|
+
qrDataUrl: string;
|
|
38
|
+
connected?: boolean;
|
|
39
|
+
}) => {
|
|
40
|
+
const text = [
|
|
41
|
+
params.message,
|
|
42
|
+
"",
|
|
43
|
+
"Open WhatsApp → Linked Devices and scan:",
|
|
44
|
+
"",
|
|
45
|
+
``,
|
|
46
|
+
].join("\n");
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text" as const, text }],
|
|
49
|
+
details: {
|
|
50
|
+
connected: params.connected ?? false,
|
|
51
|
+
qr: true,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const action = (args as { action?: string })?.action ?? "start";
|
|
57
|
+
const accountId = readOptionalString((args as { accountId?: unknown }).accountId);
|
|
58
|
+
if (action === "wait") {
|
|
59
|
+
const result = await waitForWebLogin({
|
|
60
|
+
accountId,
|
|
61
|
+
timeoutMs:
|
|
62
|
+
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
|
|
63
|
+
? (args as { timeoutMs?: number }).timeoutMs
|
|
64
|
+
: undefined,
|
|
65
|
+
currentQrDataUrl: readOptionalString(
|
|
66
|
+
(args as { currentQrDataUrl?: unknown }).currentQrDataUrl,
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
if (result.qrDataUrl) {
|
|
70
|
+
return renderQrReply({
|
|
71
|
+
message: result.message,
|
|
72
|
+
qrDataUrl: result.qrDataUrl,
|
|
73
|
+
connected: result.connected,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: result.message }],
|
|
78
|
+
details: { connected: result.connected },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await startWebLoginWithQr({
|
|
83
|
+
accountId,
|
|
84
|
+
timeoutMs:
|
|
85
|
+
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
|
|
86
|
+
? (args as { timeoutMs?: number }).timeoutMs
|
|
87
|
+
: undefined,
|
|
88
|
+
force:
|
|
89
|
+
typeof (args as { force?: unknown }).force === "boolean"
|
|
90
|
+
? (args as { force?: boolean }).force
|
|
91
|
+
: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!result.qrDataUrl) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: result.message,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
details: { qr: false },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return renderQrReply({
|
|
107
|
+
message: result.message,
|
|
108
|
+
qrDataUrl: result.qrDataUrl,
|
|
109
|
+
connected: result.connected,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { whatsappApprovalAuth } from "./approval-auth.js";
|
|
3
|
+
|
|
4
|
+
describe("whatsappApprovalAuth", () => {
|
|
5
|
+
it("authorizes direct WhatsApp recipients and ignores groups", () => {
|
|
6
|
+
expect(
|
|
7
|
+
whatsappApprovalAuth.authorizeActorAction({
|
|
8
|
+
cfg: { channels: { whatsapp: { allowFrom: ["+1 (555) 123-0000"] } } },
|
|
9
|
+
senderId: "15551230000@s.whatsapp.net",
|
|
10
|
+
action: "approve",
|
|
11
|
+
approvalKind: "exec",
|
|
12
|
+
}),
|
|
13
|
+
).toEqual({ authorized: true });
|
|
14
|
+
|
|
15
|
+
expect(
|
|
16
|
+
whatsappApprovalAuth.authorizeActorAction({
|
|
17
|
+
cfg: { channels: { whatsapp: { allowFrom: ["12345-67890@g.us"] } } },
|
|
18
|
+
senderId: "+15551239999",
|
|
19
|
+
action: "approve",
|
|
20
|
+
approvalKind: "exec",
|
|
21
|
+
}),
|
|
22
|
+
).toEqual({ authorized: true });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createResolvedApproverActionAuthAdapter,
|
|
3
|
+
resolveApprovalApprovers,
|
|
4
|
+
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
|
5
|
+
import { resolveWhatsAppAccount } from "./accounts.js";
|
|
6
|
+
import { normalizeWhatsAppTarget } from "./normalize.js";
|
|
7
|
+
|
|
8
|
+
function normalizeWhatsAppApproverId(value: string | number): string | undefined {
|
|
9
|
+
const normalized = normalizeWhatsAppTarget(String(value));
|
|
10
|
+
if (!normalized || normalized.endsWith("@g.us")) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const whatsappApprovalAuth = createResolvedApproverActionAuthAdapter({
|
|
17
|
+
channelLabel: "WhatsApp",
|
|
18
|
+
resolveApprovers: ({ cfg, accountId }) => {
|
|
19
|
+
const account = resolveWhatsAppAccount({ cfg, accountId });
|
|
20
|
+
return resolveApprovalApprovers({
|
|
21
|
+
allowFrom: account.allowFrom,
|
|
22
|
+
defaultTo: account.defaultTo,
|
|
23
|
+
normalizeApprover: normalizeWhatsAppApproverId,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
normalizeSenderId: (value) => normalizeWhatsAppApproverId(value),
|
|
27
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
logoutWeb,
|
|
7
|
+
pickWebChannel,
|
|
8
|
+
readWebAuthSnapshot,
|
|
9
|
+
readWebAuthState,
|
|
10
|
+
restoreCredsFromBackupIfNeeded,
|
|
11
|
+
webAuthExists,
|
|
12
|
+
WhatsAppAuthUnstableError,
|
|
13
|
+
WHATSAPP_AUTH_UNSTABLE_CODE,
|
|
14
|
+
} from "./auth-store.js";
|
|
15
|
+
import type { CredsQueueWaitResult } from "./creds-persistence.js";
|
|
16
|
+
|
|
17
|
+
const hoisted = vi.hoisted(() => ({
|
|
18
|
+
waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise<CredsQueueWaitResult>>(
|
|
19
|
+
async () => "drained",
|
|
20
|
+
),
|
|
21
|
+
oauthDir: "/tmp/openclaw-wa-auth-store-test-oauth",
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("./creds-persistence.js", async () => {
|
|
25
|
+
const actual =
|
|
26
|
+
await vi.importActual<typeof import("./creds-persistence.js")>("./creds-persistence.js");
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
waitForCredsSaveQueueWithTimeout: hoisted.waitForCredsSaveQueueWithTimeout,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
vi.mock("./auth-store.runtime.js", () => ({
|
|
34
|
+
resolveOAuthDir: () => hoisted.oauthDir,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
function createTempAuthDir(prefix: string) {
|
|
38
|
+
return fsSync.mkdtempSync(
|
|
39
|
+
path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function withOwnedOAuthAuthDir<T>(
|
|
44
|
+
prefix: string,
|
|
45
|
+
run: (authDir: string) => Promise<T>,
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
const previousOAuthDir = hoisted.oauthDir;
|
|
48
|
+
const oauthDir = createTempAuthDir(`${prefix}-oauth`);
|
|
49
|
+
const authDir = path.join(oauthDir, "whatsapp", "default");
|
|
50
|
+
fsSync.mkdirSync(authDir, { recursive: true });
|
|
51
|
+
hoisted.oauthDir = oauthDir;
|
|
52
|
+
return run(authDir).finally(() => {
|
|
53
|
+
hoisted.oauthDir = previousOAuthDir;
|
|
54
|
+
fsSync.rmSync(oauthDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("auth-store", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not restore creds from backup on ordinary reads", async () => {
|
|
64
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-read");
|
|
65
|
+
const credsPath = path.join(authDir, "creds.json");
|
|
66
|
+
const backupPath = path.join(authDir, "creds.json.bak");
|
|
67
|
+
fsSync.writeFileSync(backupPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
|
|
68
|
+
|
|
69
|
+
await expect(webAuthExists(authDir)).resolves.toBe(false);
|
|
70
|
+
expect(fsSync.existsSync(credsPath)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("restores creds from a regular backup file", async () => {
|
|
74
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-restore");
|
|
75
|
+
const credsPath = path.join(authDir, "creds.json");
|
|
76
|
+
fsSync.writeFileSync(credsPath, "{", "utf-8");
|
|
77
|
+
fsSync.writeFileSync(
|
|
78
|
+
path.join(authDir, "creds.json.bak"),
|
|
79
|
+
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
|
80
|
+
"utf-8",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(true);
|
|
84
|
+
expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual({
|
|
85
|
+
me: { id: "123@s.whatsapp.net" },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses to restore creds from a symlinked backup path", async () => {
|
|
90
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink");
|
|
91
|
+
const targetPath = path.join(authDir, "backup-target.json");
|
|
92
|
+
const backupPath = path.join(authDir, "creds.json.bak");
|
|
93
|
+
const credsPath = path.join(authDir, "creds.json");
|
|
94
|
+
fsSync.writeFileSync(targetPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
|
|
95
|
+
fsSync.symlinkSync(targetPath, backupPath);
|
|
96
|
+
fsSync.writeFileSync(credsPath, "{", "utf-8");
|
|
97
|
+
|
|
98
|
+
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
|
|
99
|
+
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports linked auth state and snapshot from the shared read helper", async () => {
|
|
103
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-linked");
|
|
104
|
+
fsSync.writeFileSync(
|
|
105
|
+
path.join(authDir, "creds.json"),
|
|
106
|
+
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
|
107
|
+
"utf-8",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await expect(readWebAuthState(authDir)).resolves.toBe("linked");
|
|
111
|
+
await expect(readWebAuthSnapshot(authDir)).resolves.toMatchObject({
|
|
112
|
+
state: "linked",
|
|
113
|
+
authAgeMs: expect.any(Number),
|
|
114
|
+
selfId: expect.objectContaining({ e164: "+15551234567" }),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reports unstable auth state when the shared barrier read times out", async () => {
|
|
119
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
|
|
120
|
+
fsSync.writeFileSync(
|
|
121
|
+
path.join(authDir, "creds.json"),
|
|
122
|
+
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
|
123
|
+
"utf-8",
|
|
124
|
+
);
|
|
125
|
+
hoisted.waitForCredsSaveQueueWithTimeout
|
|
126
|
+
.mockResolvedValueOnce("timed_out")
|
|
127
|
+
.mockResolvedValueOnce("timed_out");
|
|
128
|
+
|
|
129
|
+
await expect(readWebAuthState(authDir)).resolves.toBe("unstable");
|
|
130
|
+
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
|
|
131
|
+
state: "unstable",
|
|
132
|
+
authAgeMs: null,
|
|
133
|
+
selfId: { e164: null, jid: null, lid: null },
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("clears unreadable auth state on explicit logout", async () => {
|
|
138
|
+
await withOwnedOAuthAuthDir("openclaw-wa-auth-logout", async (authDir) => {
|
|
139
|
+
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8");
|
|
140
|
+
fsSync.writeFileSync(
|
|
141
|
+
path.join(authDir, "creds.json.bak"),
|
|
142
|
+
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
|
143
|
+
"utf-8",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const runtime = {
|
|
147
|
+
log: vi.fn(),
|
|
148
|
+
error: vi.fn(),
|
|
149
|
+
exit: vi.fn(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
|
|
153
|
+
expect(fsSync.existsSync(authDir)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("does not delete the whole legacy auth root when targeted cleanup fails", async () => {
|
|
158
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-legacy-failure");
|
|
159
|
+
const previousOAuthDir = hoisted.oauthDir;
|
|
160
|
+
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
|
|
161
|
+
fsSync.writeFileSync(path.join(authDir, "oauth.json"), '{"token":true}', "utf-8");
|
|
162
|
+
fsSync.writeFileSync(path.join(authDir, "session-abc.json"), "{}", "utf-8");
|
|
163
|
+
hoisted.oauthDir = authDir;
|
|
164
|
+
const originalRm = fs.rm;
|
|
165
|
+
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
|
|
166
|
+
if (String(target).endsWith("creds.json")) {
|
|
167
|
+
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
168
|
+
}
|
|
169
|
+
return await originalRm.call(fs, target, options as never);
|
|
170
|
+
});
|
|
171
|
+
const runtime = {
|
|
172
|
+
log: vi.fn(),
|
|
173
|
+
error: vi.fn(),
|
|
174
|
+
exit: vi.fn(),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await expect(
|
|
179
|
+
logoutWeb({ authDir, isLegacyAuthDir: true, runtime: runtime as never }),
|
|
180
|
+
).rejects.toThrow("EACCES");
|
|
181
|
+
expect(fsSync.existsSync(authDir)).toBe(true);
|
|
182
|
+
expect(fsSync.existsSync(path.join(authDir, "oauth.json"))).toBe(true);
|
|
183
|
+
} finally {
|
|
184
|
+
hoisted.oauthDir = previousOAuthDir;
|
|
185
|
+
rmSpy.mockRestore();
|
|
186
|
+
fsSync.rmSync(authDir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("clears auth state even when directory enumeration fails", async () => {
|
|
191
|
+
await withOwnedOAuthAuthDir("openclaw-wa-auth-readdir", async (authDir) => {
|
|
192
|
+
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
|
|
193
|
+
const readdirSpy = vi
|
|
194
|
+
.spyOn(fs, "readdir")
|
|
195
|
+
.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" }));
|
|
196
|
+
const runtime = {
|
|
197
|
+
log: vi.fn(),
|
|
198
|
+
error: vi.fn(),
|
|
199
|
+
exit: vi.fn(),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
|
|
203
|
+
expect(fsSync.existsSync(authDir)).toBe(false);
|
|
204
|
+
readdirSpy.mockRestore();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not delete custom auth directories outside the OpenClaw auth root", async () => {
|
|
209
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-custom");
|
|
210
|
+
const nestedDir = path.join(authDir, "nested");
|
|
211
|
+
fsSync.mkdirSync(nestedDir);
|
|
212
|
+
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
|
|
213
|
+
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8");
|
|
214
|
+
fsSync.writeFileSync(path.join(nestedDir, "session-abc.json"), "keep me", "utf-8");
|
|
215
|
+
const runtime = {
|
|
216
|
+
log: vi.fn(),
|
|
217
|
+
error: vi.fn(),
|
|
218
|
+
exit: vi.fn(),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
|
|
222
|
+
expect(fsSync.existsSync(authDir)).toBe(true);
|
|
223
|
+
expect(fsSync.existsSync(path.join(authDir, "creds.json"))).toBe(true);
|
|
224
|
+
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
|
|
225
|
+
expect(fsSync.existsSync(path.join(nestedDir, "session-abc.json"))).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not clear auth files through a symlinked owned auth directory", async () => {
|
|
229
|
+
const previousOAuthDir = hoisted.oauthDir;
|
|
230
|
+
const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-oauth");
|
|
231
|
+
const externalDir = createTempAuthDir("openclaw-wa-auth-symlink-target");
|
|
232
|
+
const authDir = path.join(oauthDir, "whatsapp", "default");
|
|
233
|
+
try {
|
|
234
|
+
fsSync.mkdirSync(path.dirname(authDir), { recursive: true });
|
|
235
|
+
fsSync.writeFileSync(path.join(externalDir, "creds.json"), "{}", "utf-8");
|
|
236
|
+
fsSync.writeFileSync(path.join(externalDir, "notes.txt"), "keep me", "utf-8");
|
|
237
|
+
fsSync.symlinkSync(externalDir, authDir, "dir");
|
|
238
|
+
hoisted.oauthDir = oauthDir;
|
|
239
|
+
const runtime = {
|
|
240
|
+
log: vi.fn(),
|
|
241
|
+
error: vi.fn(),
|
|
242
|
+
exit: vi.fn(),
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
|
|
246
|
+
expect(fsSync.existsSync(authDir)).toBe(true);
|
|
247
|
+
expect(fsSync.existsSync(path.join(externalDir, "creds.json"))).toBe(true);
|
|
248
|
+
expect(fsSync.existsSync(path.join(externalDir, "notes.txt"))).toBe(true);
|
|
249
|
+
} finally {
|
|
250
|
+
hoisted.oauthDir = previousOAuthDir;
|
|
251
|
+
fsSync.rmSync(oauthDir, { recursive: true, force: true });
|
|
252
|
+
fsSync.rmSync(externalDir, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("does not clear auth files through an intermediate symlink in the owned auth tree", async () => {
|
|
257
|
+
const previousOAuthDir = hoisted.oauthDir;
|
|
258
|
+
const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-parent-oauth");
|
|
259
|
+
const externalRoot = createTempAuthDir("openclaw-wa-auth-symlink-parent-target");
|
|
260
|
+
const externalAuthDir = path.join(externalRoot, "default");
|
|
261
|
+
const linkedParent = path.join(oauthDir, "whatsapp", "linked");
|
|
262
|
+
const authDir = path.join(linkedParent, "default");
|
|
263
|
+
try {
|
|
264
|
+
fsSync.mkdirSync(path.dirname(linkedParent), { recursive: true });
|
|
265
|
+
fsSync.mkdirSync(externalAuthDir, { recursive: true });
|
|
266
|
+
fsSync.writeFileSync(path.join(externalAuthDir, "creds.json"), "{}", "utf-8");
|
|
267
|
+
fsSync.writeFileSync(path.join(externalAuthDir, "notes.txt"), "keep me", "utf-8");
|
|
268
|
+
fsSync.symlinkSync(externalRoot, linkedParent, "dir");
|
|
269
|
+
hoisted.oauthDir = oauthDir;
|
|
270
|
+
const runtime = {
|
|
271
|
+
log: vi.fn(),
|
|
272
|
+
error: vi.fn(),
|
|
273
|
+
exit: vi.fn(),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
|
|
277
|
+
expect(fsSync.existsSync(authDir)).toBe(true);
|
|
278
|
+
expect(fsSync.existsSync(path.join(externalAuthDir, "creds.json"))).toBe(true);
|
|
279
|
+
expect(fsSync.existsSync(path.join(externalAuthDir, "notes.txt"))).toBe(true);
|
|
280
|
+
} finally {
|
|
281
|
+
hoisted.oauthDir = previousOAuthDir;
|
|
282
|
+
fsSync.rmSync(oauthDir, { recursive: true, force: true });
|
|
283
|
+
fsSync.rmSync(externalRoot, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("does not delete unrelated non-empty directories on logout", async () => {
|
|
288
|
+
const authDir = createTempAuthDir("openclaw-wa-auth-unrelated");
|
|
289
|
+
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8");
|
|
290
|
+
const runtime = {
|
|
291
|
+
log: vi.fn(),
|
|
292
|
+
error: vi.fn(),
|
|
293
|
+
exit: vi.fn(),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
|
|
297
|
+
expect(fsSync.existsSync(authDir)).toBe(true);
|
|
298
|
+
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("throws a typed unstable-auth error when channel selection times out", async () => {
|
|
302
|
+
hoisted.waitForCredsSaveQueueWithTimeout.mockResolvedValueOnce("timed_out");
|
|
303
|
+
|
|
304
|
+
await expect(pickWebChannel("auto", "/tmp/openclaw-wa-auth-unstable")).rejects.toEqual(
|
|
305
|
+
expect.objectContaining({
|
|
306
|
+
code: WHATSAPP_AUTH_UNSTABLE_CODE,
|
|
307
|
+
name: WhatsAppAuthUnstableError.name,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|