@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,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { captureEnv } from "openclaw/plugin-sdk/test-env";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js";
|
|
7
|
+
|
|
8
|
+
describe("hasAnyWhatsAppAuth", () => {
|
|
9
|
+
let envSnapshot: ReturnType<typeof captureEnv>;
|
|
10
|
+
let tempOauthDir: string | undefined;
|
|
11
|
+
|
|
12
|
+
const writeCreds = (dir: string) => {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(dir, "creds.json"), JSON.stringify({ me: {} }));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
envSnapshot = captureEnv(["OPENCLAW_OAUTH_DIR"]);
|
|
19
|
+
tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-"));
|
|
20
|
+
process.env.OPENCLAW_OAUTH_DIR = tempOauthDir;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
envSnapshot.restore();
|
|
25
|
+
if (tempOauthDir) {
|
|
26
|
+
fs.rmSync(tempOauthDir, { recursive: true, force: true });
|
|
27
|
+
tempOauthDir = undefined;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false when no auth exists", () => {
|
|
32
|
+
expect(hasAnyWhatsAppAuth({})).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns true when legacy auth exists", () => {
|
|
36
|
+
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), JSON.stringify({ me: {} }));
|
|
37
|
+
expect(hasAnyWhatsAppAuth({})).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns true when non-default auth exists", () => {
|
|
41
|
+
writeCreds(path.join(tempOauthDir ?? "", "whatsapp", "work"));
|
|
42
|
+
expect(hasAnyWhatsAppAuth({})).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("includes authDir overrides", () => {
|
|
46
|
+
const customDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-wa-auth-"));
|
|
47
|
+
try {
|
|
48
|
+
writeCreds(customDir);
|
|
49
|
+
const cfg = {
|
|
50
|
+
channels: { whatsapp: { accounts: { work: { authDir: customDir } } } },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
expect(listWhatsAppAuthDirs(cfg)).toContain(customDir);
|
|
54
|
+
expect(hasAnyWhatsAppAuth(cfg)).toBe(true);
|
|
55
|
+
} finally {
|
|
56
|
+
fs.rmSync(customDir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
3
|
+
import { resolveWhatsAppAccount } from "./accounts.js";
|
|
4
|
+
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
|
|
5
|
+
|
|
6
|
+
export function resolveAuthorizedWhatsAppOutboundTarget(params: {
|
|
7
|
+
cfg: OpenClawConfig;
|
|
8
|
+
chatJid: string;
|
|
9
|
+
accountId?: string;
|
|
10
|
+
actionLabel: string;
|
|
11
|
+
}): { to: string; accountId: string } {
|
|
12
|
+
const account = resolveWhatsAppAccount({
|
|
13
|
+
cfg: params.cfg,
|
|
14
|
+
accountId: params.accountId,
|
|
15
|
+
});
|
|
16
|
+
const resolution = resolveWhatsAppOutboundTarget({
|
|
17
|
+
to: params.chatJid,
|
|
18
|
+
allowFrom: account.allowFrom ?? [],
|
|
19
|
+
mode: "implicit",
|
|
20
|
+
});
|
|
21
|
+
if (!resolution.ok) {
|
|
22
|
+
throw new ToolAuthorizationError(
|
|
23
|
+
`WhatsApp ${params.actionLabel} blocked: chatJid "${params.chatJid}" is not in the configured allowFrom list for account "${account.accountId}".`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return { to: resolution.to, accountId: account.accountId };
|
|
27
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js";
|
|
5
|
+
|
|
6
|
+
const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime };
|
|
7
|
+
const sendReactionWhatsApp = vi.fn(async () => undefined);
|
|
8
|
+
|
|
9
|
+
const enabledConfig = {
|
|
10
|
+
channels: { whatsapp: { actions: { reactions: true } } },
|
|
11
|
+
} as OpenClawConfig;
|
|
12
|
+
|
|
13
|
+
describe("handleWhatsAppAction", () => {
|
|
14
|
+
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
|
|
15
|
+
return {
|
|
16
|
+
channels: { whatsapp: { actions: { reactions: true }, reactionLevel } },
|
|
17
|
+
} as OpenClawConfig;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, {
|
|
23
|
+
sendReactionWhatsApp,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("adds reactions", async () => {
|
|
28
|
+
await handleWhatsAppAction(
|
|
29
|
+
{
|
|
30
|
+
action: "react",
|
|
31
|
+
chatJid: "123@s.whatsapp.net",
|
|
32
|
+
messageId: "msg1",
|
|
33
|
+
emoji: "✅",
|
|
34
|
+
},
|
|
35
|
+
enabledConfig,
|
|
36
|
+
);
|
|
37
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
38
|
+
"+123",
|
|
39
|
+
"msg1",
|
|
40
|
+
"✅",
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
verbose: false,
|
|
43
|
+
fromMe: undefined,
|
|
44
|
+
participant: undefined,
|
|
45
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("adds reactions when reactionLevel is minimal", async () => {
|
|
51
|
+
await handleWhatsAppAction(
|
|
52
|
+
{
|
|
53
|
+
action: "react",
|
|
54
|
+
chatJid: "123@s.whatsapp.net",
|
|
55
|
+
messageId: "msg1",
|
|
56
|
+
emoji: "✅",
|
|
57
|
+
},
|
|
58
|
+
reactionConfig("minimal"),
|
|
59
|
+
);
|
|
60
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
61
|
+
"+123",
|
|
62
|
+
"msg1",
|
|
63
|
+
"✅",
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
verbose: false,
|
|
66
|
+
fromMe: undefined,
|
|
67
|
+
participant: undefined,
|
|
68
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("adds reactions when reactionLevel is extensive", async () => {
|
|
74
|
+
await handleWhatsAppAction(
|
|
75
|
+
{
|
|
76
|
+
action: "react",
|
|
77
|
+
chatJid: "123@s.whatsapp.net",
|
|
78
|
+
messageId: "msg1",
|
|
79
|
+
emoji: "✅",
|
|
80
|
+
},
|
|
81
|
+
reactionConfig("extensive"),
|
|
82
|
+
);
|
|
83
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
84
|
+
"+123",
|
|
85
|
+
"msg1",
|
|
86
|
+
"✅",
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
verbose: false,
|
|
89
|
+
fromMe: undefined,
|
|
90
|
+
participant: undefined,
|
|
91
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("removes reactions on empty emoji", async () => {
|
|
97
|
+
await handleWhatsAppAction(
|
|
98
|
+
{
|
|
99
|
+
action: "react",
|
|
100
|
+
chatJid: "123@s.whatsapp.net",
|
|
101
|
+
messageId: "msg1",
|
|
102
|
+
emoji: "",
|
|
103
|
+
},
|
|
104
|
+
enabledConfig,
|
|
105
|
+
);
|
|
106
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
107
|
+
"+123",
|
|
108
|
+
"msg1",
|
|
109
|
+
"",
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
verbose: false,
|
|
112
|
+
fromMe: undefined,
|
|
113
|
+
participant: undefined,
|
|
114
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("removes reactions when remove flag set", async () => {
|
|
120
|
+
await handleWhatsAppAction(
|
|
121
|
+
{
|
|
122
|
+
action: "react",
|
|
123
|
+
chatJid: "123@s.whatsapp.net",
|
|
124
|
+
messageId: "msg1",
|
|
125
|
+
emoji: "✅",
|
|
126
|
+
remove: true,
|
|
127
|
+
},
|
|
128
|
+
enabledConfig,
|
|
129
|
+
);
|
|
130
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
131
|
+
"+123",
|
|
132
|
+
"msg1",
|
|
133
|
+
"",
|
|
134
|
+
expect.objectContaining({
|
|
135
|
+
verbose: false,
|
|
136
|
+
fromMe: undefined,
|
|
137
|
+
participant: undefined,
|
|
138
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("passes account scope and sender flags", async () => {
|
|
144
|
+
await handleWhatsAppAction(
|
|
145
|
+
{
|
|
146
|
+
action: "react",
|
|
147
|
+
chatJid: "123@s.whatsapp.net",
|
|
148
|
+
messageId: "msg1",
|
|
149
|
+
emoji: "🎉",
|
|
150
|
+
accountId: "work",
|
|
151
|
+
fromMe: true,
|
|
152
|
+
participant: "999@s.whatsapp.net",
|
|
153
|
+
},
|
|
154
|
+
enabledConfig,
|
|
155
|
+
);
|
|
156
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
157
|
+
"+123",
|
|
158
|
+
"msg1",
|
|
159
|
+
"🎉",
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
verbose: false,
|
|
162
|
+
fromMe: true,
|
|
163
|
+
participant: "999@s.whatsapp.net",
|
|
164
|
+
accountId: "work",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("preserves LID participant ids when forwarding reactions", async () => {
|
|
170
|
+
await handleWhatsAppAction(
|
|
171
|
+
{
|
|
172
|
+
action: "react",
|
|
173
|
+
chatJid: "12345@g.us",
|
|
174
|
+
messageId: "msg1",
|
|
175
|
+
emoji: "🎉",
|
|
176
|
+
participant: "123@lid",
|
|
177
|
+
},
|
|
178
|
+
enabledConfig,
|
|
179
|
+
);
|
|
180
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
181
|
+
"12345@g.us",
|
|
182
|
+
"msg1",
|
|
183
|
+
"🎉",
|
|
184
|
+
expect.objectContaining({
|
|
185
|
+
verbose: false,
|
|
186
|
+
fromMe: undefined,
|
|
187
|
+
participant: "123@lid",
|
|
188
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("respects reaction gating", async () => {
|
|
194
|
+
const cfg = {
|
|
195
|
+
channels: { whatsapp: { actions: { reactions: false } } },
|
|
196
|
+
} as OpenClawConfig;
|
|
197
|
+
await expect(
|
|
198
|
+
handleWhatsAppAction(
|
|
199
|
+
{
|
|
200
|
+
action: "react",
|
|
201
|
+
chatJid: "123@s.whatsapp.net",
|
|
202
|
+
messageId: "msg1",
|
|
203
|
+
emoji: "✅",
|
|
204
|
+
},
|
|
205
|
+
cfg,
|
|
206
|
+
),
|
|
207
|
+
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("disables reactions when WhatsApp is not configured", async () => {
|
|
211
|
+
await expect(
|
|
212
|
+
handleWhatsAppAction(
|
|
213
|
+
{
|
|
214
|
+
action: "react",
|
|
215
|
+
chatJid: "123@s.whatsapp.net",
|
|
216
|
+
messageId: "msg1",
|
|
217
|
+
emoji: "✅",
|
|
218
|
+
},
|
|
219
|
+
{} as OpenClawConfig,
|
|
220
|
+
),
|
|
221
|
+
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("prefers the action gate error when both actions.reactions and reactionLevel disable reactions", async () => {
|
|
225
|
+
const cfg = {
|
|
226
|
+
channels: { whatsapp: { actions: { reactions: false }, reactionLevel: "ack" } },
|
|
227
|
+
} as OpenClawConfig;
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
handleWhatsAppAction(
|
|
231
|
+
{
|
|
232
|
+
action: "react",
|
|
233
|
+
chatJid: "123@s.whatsapp.net",
|
|
234
|
+
messageId: "msg1",
|
|
235
|
+
emoji: "✅",
|
|
236
|
+
},
|
|
237
|
+
cfg,
|
|
238
|
+
),
|
|
239
|
+
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
|
240
|
+
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it.each(["off", "ack"] as const)(
|
|
244
|
+
"blocks agent reactions when reactionLevel is %s",
|
|
245
|
+
async (reactionLevel) => {
|
|
246
|
+
await expect(
|
|
247
|
+
handleWhatsAppAction(
|
|
248
|
+
{
|
|
249
|
+
action: "react",
|
|
250
|
+
chatJid: "123@s.whatsapp.net",
|
|
251
|
+
messageId: "msg1",
|
|
252
|
+
emoji: "✅",
|
|
253
|
+
},
|
|
254
|
+
reactionConfig(reactionLevel),
|
|
255
|
+
),
|
|
256
|
+
).rejects.toThrow(
|
|
257
|
+
new RegExp(`WhatsApp agent reactions disabled \\(reactionLevel="${reactionLevel}"\\)`),
|
|
258
|
+
);
|
|
259
|
+
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
it("applies default account allowFrom when accountId is omitted", async () => {
|
|
264
|
+
const cfg = {
|
|
265
|
+
channels: {
|
|
266
|
+
whatsapp: {
|
|
267
|
+
actions: { reactions: true },
|
|
268
|
+
allowFrom: ["111@s.whatsapp.net"],
|
|
269
|
+
accounts: {
|
|
270
|
+
[DEFAULT_ACCOUNT_ID]: {
|
|
271
|
+
allowFrom: ["222@s.whatsapp.net"],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
} as OpenClawConfig;
|
|
277
|
+
|
|
278
|
+
await expect(
|
|
279
|
+
handleWhatsAppAction(
|
|
280
|
+
{
|
|
281
|
+
action: "react",
|
|
282
|
+
chatJid: "111@s.whatsapp.net",
|
|
283
|
+
messageId: "msg1",
|
|
284
|
+
emoji: "✅",
|
|
285
|
+
},
|
|
286
|
+
cfg,
|
|
287
|
+
),
|
|
288
|
+
).rejects.toMatchObject({
|
|
289
|
+
name: "ToolAuthorizationError",
|
|
290
|
+
status: 403,
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("routes to resolved default account when no accountId is provided", async () => {
|
|
295
|
+
const cfg = {
|
|
296
|
+
channels: {
|
|
297
|
+
whatsapp: {
|
|
298
|
+
actions: { reactions: true },
|
|
299
|
+
accounts: {
|
|
300
|
+
work: {
|
|
301
|
+
allowFrom: ["123@s.whatsapp.net"],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
} as OpenClawConfig;
|
|
307
|
+
|
|
308
|
+
await handleWhatsAppAction(
|
|
309
|
+
{
|
|
310
|
+
action: "react",
|
|
311
|
+
chatJid: "123@s.whatsapp.net",
|
|
312
|
+
messageId: "msg1",
|
|
313
|
+
emoji: "✅",
|
|
314
|
+
},
|
|
315
|
+
cfg,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
319
|
+
"+123",
|
|
320
|
+
"msg1",
|
|
321
|
+
"✅",
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
verbose: false,
|
|
324
|
+
fromMe: undefined,
|
|
325
|
+
participant: undefined,
|
|
326
|
+
accountId: "work",
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import {
|
|
3
|
+
createActionGate,
|
|
4
|
+
jsonResult,
|
|
5
|
+
readReactionParams,
|
|
6
|
+
readStringParam,
|
|
7
|
+
} from "openclaw/plugin-sdk/channel-actions";
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
9
|
+
import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js";
|
|
10
|
+
import { resolveWhatsAppReactionLevel } from "./reaction-level.js";
|
|
11
|
+
import { sendReactionWhatsApp } from "./send.js";
|
|
12
|
+
|
|
13
|
+
export const whatsAppActionRuntime = {
|
|
14
|
+
resolveAuthorizedWhatsAppOutboundTarget,
|
|
15
|
+
sendReactionWhatsApp,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function handleWhatsAppAction(
|
|
19
|
+
params: Record<string, unknown>,
|
|
20
|
+
cfg: OpenClawConfig,
|
|
21
|
+
): Promise<AgentToolResult<unknown>> {
|
|
22
|
+
const action = readStringParam(params, "action", { required: true });
|
|
23
|
+
const whatsAppConfig = cfg.channels?.whatsapp;
|
|
24
|
+
const isActionEnabled = createActionGate(whatsAppConfig?.actions);
|
|
25
|
+
|
|
26
|
+
if (action === "react") {
|
|
27
|
+
const accountId = readStringParam(params, "accountId");
|
|
28
|
+
if (!whatsAppConfig) {
|
|
29
|
+
throw new Error("WhatsApp reactions are disabled.");
|
|
30
|
+
}
|
|
31
|
+
if (!isActionEnabled("reactions")) {
|
|
32
|
+
throw new Error("WhatsApp reactions are disabled.");
|
|
33
|
+
}
|
|
34
|
+
const reactionLevelInfo = resolveWhatsAppReactionLevel({
|
|
35
|
+
cfg,
|
|
36
|
+
accountId: accountId ?? undefined,
|
|
37
|
+
});
|
|
38
|
+
if (!reactionLevelInfo.agentReactionsEnabled) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`WhatsApp agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
|
41
|
+
`Set channels.whatsapp.reactionLevel to "minimal" or "extensive" to enable.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const chatJid = readStringParam(params, "chatJid", { required: true });
|
|
45
|
+
const messageId = readStringParam(params, "messageId", { required: true });
|
|
46
|
+
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
|
47
|
+
removeErrorMessage: "Emoji is required to remove a WhatsApp reaction.",
|
|
48
|
+
});
|
|
49
|
+
const participant = readStringParam(params, "participant");
|
|
50
|
+
const fromMeRaw = params.fromMe;
|
|
51
|
+
const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined;
|
|
52
|
+
|
|
53
|
+
// Resolve account + allowFrom via shared account logic so auth and routing stay aligned.
|
|
54
|
+
const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({
|
|
55
|
+
cfg,
|
|
56
|
+
chatJid,
|
|
57
|
+
accountId,
|
|
58
|
+
actionLabel: "reaction",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const resolvedEmoji = remove ? "" : emoji;
|
|
62
|
+
await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, {
|
|
63
|
+
verbose: false,
|
|
64
|
+
fromMe,
|
|
65
|
+
participant: participant ?? undefined,
|
|
66
|
+
accountId: resolved.accountId,
|
|
67
|
+
cfg,
|
|
68
|
+
});
|
|
69
|
+
if (!remove && !isEmpty) {
|
|
70
|
+
return jsonResult({ ok: true, added: emoji });
|
|
71
|
+
}
|
|
72
|
+
return jsonResult({ ok: true, removed: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error(`Unsupported WhatsApp action: ${action}`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getActiveWebListener, resolveWebAccountId } from "./active-listener.js";
|
|
3
|
+
|
|
4
|
+
const registryMocks = vi.hoisted(() => ({
|
|
5
|
+
getRegisteredWhatsAppConnectionController: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("./connection-controller-registry.js", () => ({
|
|
9
|
+
getRegisteredWhatsAppConnectionController:
|
|
10
|
+
registryMocks.getRegisteredWhatsAppConnectionController,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const WHATSAPP_ACTIVE_LISTENER_TEST_CFG = {
|
|
14
|
+
channels: { whatsapp: { accounts: { work: { enabled: true } }, defaultAccount: "work" } },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function makeListener() {
|
|
18
|
+
return {
|
|
19
|
+
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
|
|
20
|
+
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
|
21
|
+
sendReaction: vi.fn(async () => {}),
|
|
22
|
+
sendComposingTo: vi.fn(async () => {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
registryMocks.getRegisteredWhatsAppConnectionController.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("active WhatsApp listener view", () => {
|
|
31
|
+
it("reads controller-backed state", () => {
|
|
32
|
+
const listener = makeListener();
|
|
33
|
+
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
|
|
34
|
+
(accountId: string) =>
|
|
35
|
+
accountId === "work"
|
|
36
|
+
? {
|
|
37
|
+
getActiveListener: () => listener,
|
|
38
|
+
}
|
|
39
|
+
: null,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(getActiveWebListener("work")).toBe(listener);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("resolves the configured default account when accountId is omitted", () => {
|
|
46
|
+
const listener = makeListener();
|
|
47
|
+
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
|
|
48
|
+
(accountId: string) =>
|
|
49
|
+
accountId === "work"
|
|
50
|
+
? {
|
|
51
|
+
getActiveListener: () => listener,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(resolveWebAccountId({ cfg: WHATSAPP_ACTIVE_LISTENER_TEST_CFG })).toBe("work");
|
|
57
|
+
expect(getActiveWebListener("work")).toBe(listener);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null when the controller has no active listener for the account", () => {
|
|
61
|
+
registryMocks.getRegisteredWhatsAppConnectionController.mockReturnValue(null);
|
|
62
|
+
|
|
63
|
+
expect(getActiveWebListener("work")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
2
|
+
import { resolveDefaultWhatsAppAccountId } from "./account-ids.js";
|
|
3
|
+
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
|
4
|
+
import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
|
|
5
|
+
|
|
6
|
+
export type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
|
|
7
|
+
|
|
8
|
+
export function resolveWebAccountId(params: {
|
|
9
|
+
cfg: OpenClawConfig;
|
|
10
|
+
accountId?: string | null;
|
|
11
|
+
}): string {
|
|
12
|
+
return (params.accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(params.cfg);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getActiveWebListener(accountId: string): ActiveWebListener | null {
|
|
16
|
+
return getRegisteredWhatsAppConnectionController(accountId)?.getActiveListener() ?? null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
|
|
3
|
+
import { createWhatsAppLoginTool } from "./agent-tools-login.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("../login-qr-api.js", () => ({
|
|
6
|
+
startWebLoginWithQr: vi.fn(),
|
|
7
|
+
waitForWebLogin: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const startWebLoginWithQrMock = vi.mocked(startWebLoginWithQr);
|
|
11
|
+
const waitForWebLoginMock = vi.mocked(waitForWebLogin);
|
|
12
|
+
|
|
13
|
+
describe("createWhatsAppLoginTool", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("passes the caller's current QR back into wait actions", async () => {
|
|
19
|
+
const accountId = "account-1";
|
|
20
|
+
waitForWebLoginMock.mockResolvedValueOnce({
|
|
21
|
+
connected: false,
|
|
22
|
+
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
|
|
23
|
+
qrDataUrl: "data:image/png;base64,next-qr",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const tool = createWhatsAppLoginTool();
|
|
27
|
+
const result = await tool.execute("tool-call-1", {
|
|
28
|
+
action: "wait",
|
|
29
|
+
timeoutMs: 5000,
|
|
30
|
+
accountId,
|
|
31
|
+
currentQrDataUrl: "data:image/png;base64,current-qr",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(waitForWebLoginMock).toHaveBeenCalledWith({
|
|
35
|
+
accountId,
|
|
36
|
+
timeoutMs: 5000,
|
|
37
|
+
currentQrDataUrl: "data:image/png;base64,current-qr",
|
|
38
|
+
});
|
|
39
|
+
expect(result).toEqual({
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: [
|
|
44
|
+
"QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
|
|
45
|
+
"",
|
|
46
|
+
"Open WhatsApp → Linked Devices and scan:",
|
|
47
|
+
"",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
details: {
|
|
53
|
+
connected: false,
|
|
54
|
+
qr: true,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not retain QR state across tool actions", async () => {
|
|
60
|
+
const accountId = "account-2";
|
|
61
|
+
startWebLoginWithQrMock.mockResolvedValueOnce({
|
|
62
|
+
connected: false,
|
|
63
|
+
message: "Scan this QR in WhatsApp → Linked Devices.",
|
|
64
|
+
qrDataUrl: "data:image/png;base64,current-qr",
|
|
65
|
+
});
|
|
66
|
+
waitForWebLoginMock.mockResolvedValueOnce({
|
|
67
|
+
connected: true,
|
|
68
|
+
message: "✅ Linked! WhatsApp is ready.",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const tool = createWhatsAppLoginTool();
|
|
72
|
+
await tool.execute("tool-call-start", { action: "start", accountId });
|
|
73
|
+
await tool.execute("tool-call-wait", { action: "wait", timeoutMs: 5000, accountId });
|
|
74
|
+
|
|
75
|
+
expect(waitForWebLoginMock).toHaveBeenCalledWith({
|
|
76
|
+
accountId,
|
|
77
|
+
timeoutMs: 5000,
|
|
78
|
+
currentQrDataUrl: undefined,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|