@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,203 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js";
|
|
3
|
+
import type { OpenClawConfig } from "./runtime-api.js";
|
|
4
|
+
|
|
5
|
+
const loadSessionStoreMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const readChannelAllowFromStoreSyncMock = vi.hoisted(() => vi.fn<() => string[]>(() => []));
|
|
7
|
+
|
|
8
|
+
vi.mock("./heartbeat-recipients.runtime.js", () => ({
|
|
9
|
+
DEFAULT_ACCOUNT_ID: "default",
|
|
10
|
+
loadSessionStore: loadSessionStoreMock,
|
|
11
|
+
readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock,
|
|
12
|
+
resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"),
|
|
13
|
+
normalizeChannelId: (value?: string | null) => {
|
|
14
|
+
const trimmed = value?.trim().toLowerCase();
|
|
15
|
+
return trimmed ? (trimmed as "whatsapp") : null;
|
|
16
|
+
},
|
|
17
|
+
normalizeE164: (value?: string | null) => {
|
|
18
|
+
const digits = (value ?? "").replace(/[^\d+]/g, "");
|
|
19
|
+
if (!digits) {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
return digits.startsWith("+") ? digits : `+${digits}`;
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
|
|
27
|
+
return {
|
|
28
|
+
bindings: [],
|
|
29
|
+
channels: {},
|
|
30
|
+
...overrides,
|
|
31
|
+
} as OpenClawConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("resolveWhatsAppHeartbeatRecipients", () => {
|
|
35
|
+
function setSessionStore(store: Record<string, unknown>) {
|
|
36
|
+
loadSessionStoreMock.mockReturnValue(store);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setAllowFromStore(entries: string[]) {
|
|
40
|
+
readChannelAllowFromStoreSyncMock.mockReturnValue(entries);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveWith(
|
|
44
|
+
cfgOverrides: Partial<OpenClawConfig> = {},
|
|
45
|
+
opts?: Parameters<typeof resolveWhatsAppHeartbeatRecipients>[1],
|
|
46
|
+
) {
|
|
47
|
+
return resolveWhatsAppHeartbeatRecipients(makeCfg(cfgOverrides), opts);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setSingleUnauthorizedSessionWithAllowFrom() {
|
|
51
|
+
setSessionStore({
|
|
52
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
|
|
53
|
+
});
|
|
54
|
+
setAllowFromStore(["+15550000001"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
loadSessionStoreMock.mockReset();
|
|
59
|
+
readChannelAllowFromStoreSyncMock.mockReset();
|
|
60
|
+
loadSessionStoreMock.mockReturnValue({});
|
|
61
|
+
setAllowFromStore([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses allowFrom store recipients when session recipients are ambiguous", () => {
|
|
65
|
+
setSessionStore({
|
|
66
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
|
|
67
|
+
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
|
|
68
|
+
});
|
|
69
|
+
setAllowFromStore(["+15550000001"]);
|
|
70
|
+
|
|
71
|
+
const result = resolveWith();
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("falls back to allowFrom when no session recipient is authorized", () => {
|
|
77
|
+
setSingleUnauthorizedSessionWithAllowFrom();
|
|
78
|
+
|
|
79
|
+
const result = resolveWith();
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual({ recipients: ["+15550000001"], source: "allowFrom" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("includes both session and allowFrom recipients when --all is set", () => {
|
|
85
|
+
setSingleUnauthorizedSessionWithAllowFrom();
|
|
86
|
+
|
|
87
|
+
const result = resolveWith({}, { all: true });
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({
|
|
90
|
+
recipients: ["+15550000099", "+15550000001"],
|
|
91
|
+
source: "all",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns explicit --to recipient and source flag", () => {
|
|
96
|
+
setSessionStore({
|
|
97
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
|
|
98
|
+
});
|
|
99
|
+
const result = resolveWith({}, { to: " +1 555 000 7777 " });
|
|
100
|
+
expect(result).toEqual({ recipients: ["+15550007777"], source: "flag" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns ambiguous session recipients when no allowFrom list exists", () => {
|
|
104
|
+
setSessionStore({
|
|
105
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
|
|
106
|
+
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
|
|
107
|
+
});
|
|
108
|
+
const result = resolveWith();
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
recipients: ["+15550000001", "+15550000002"],
|
|
111
|
+
source: "session-ambiguous",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns single session recipient when allowFrom is empty", () => {
|
|
116
|
+
setSessionStore({
|
|
117
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
|
|
118
|
+
});
|
|
119
|
+
const result = resolveWith();
|
|
120
|
+
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns all authorized session recipients when allowFrom matches multiple", () => {
|
|
124
|
+
setSessionStore({
|
|
125
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
|
|
126
|
+
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
|
|
127
|
+
c: { lastChannel: "whatsapp", lastTo: "+15550000003", updatedAt: 0, sessionId: "c" },
|
|
128
|
+
});
|
|
129
|
+
setAllowFromStore(["+15550000001", "+15550000002"]);
|
|
130
|
+
const result = resolveWith();
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
recipients: ["+15550000001", "+15550000002"],
|
|
133
|
+
source: "session-ambiguous",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("ignores session store when session scope is global", () => {
|
|
138
|
+
setSessionStore({
|
|
139
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
|
|
140
|
+
});
|
|
141
|
+
const result = resolveWith({
|
|
142
|
+
session: { scope: "global" } as OpenClawConfig["session"],
|
|
143
|
+
channels: { whatsapp: { allowFrom: ["*", "+15550000009"] } as never },
|
|
144
|
+
});
|
|
145
|
+
expect(result).toEqual({ recipients: ["+15550000009"], source: "allowFrom" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("uses the requested account allowFrom config and pairing store", () => {
|
|
149
|
+
setSessionStore({
|
|
150
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" },
|
|
151
|
+
});
|
|
152
|
+
setAllowFromStore(["+15550000002"]);
|
|
153
|
+
|
|
154
|
+
const result = resolveWith(
|
|
155
|
+
{
|
|
156
|
+
channels: {
|
|
157
|
+
whatsapp: {
|
|
158
|
+
allowFrom: ["+15550000001"],
|
|
159
|
+
accounts: {
|
|
160
|
+
work: {
|
|
161
|
+
allowFrom: ["+15550000003"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
} as never,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{ accountId: "work" },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(readChannelAllowFromStoreSyncMock).toHaveBeenCalledWith("whatsapp", process.env, "work");
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
recipients: ["+15550000003", "+15550000002"],
|
|
173
|
+
source: "allowFrom",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses configured defaultAccount allowFrom config and pairing store when accountId is omitted", () => {
|
|
178
|
+
setSessionStore({
|
|
179
|
+
a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" },
|
|
180
|
+
});
|
|
181
|
+
setAllowFromStore(["+15550000002"]);
|
|
182
|
+
|
|
183
|
+
const result = resolveWith({
|
|
184
|
+
channels: {
|
|
185
|
+
whatsapp: {
|
|
186
|
+
defaultAccount: "work",
|
|
187
|
+
allowFrom: ["+15550000001"],
|
|
188
|
+
accounts: {
|
|
189
|
+
work: {
|
|
190
|
+
allowFrom: ["+15550000003"],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
} as never,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(readChannelAllowFromStoreSyncMock).toHaveBeenCalledWith("whatsapp", process.env, "work");
|
|
198
|
+
expect(result).toEqual({
|
|
199
|
+
recipients: ["+15550000003", "+15550000002"],
|
|
200
|
+
source: "allowFrom",
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { resolveDefaultWhatsAppAccountId, resolveWhatsAppAccount } from "./accounts.js";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
loadSessionStore,
|
|
5
|
+
normalizeChannelId,
|
|
6
|
+
normalizeE164,
|
|
7
|
+
readChannelAllowFromStoreSync,
|
|
8
|
+
resolveStorePath,
|
|
9
|
+
type OpenClawConfig,
|
|
10
|
+
} from "./heartbeat-recipients.runtime.js";
|
|
11
|
+
|
|
12
|
+
type HeartbeatRecipientsResult = { recipients: string[]; source: string };
|
|
13
|
+
type HeartbeatRecipientsOpts = { to?: string; all?: boolean; accountId?: string };
|
|
14
|
+
|
|
15
|
+
function getSessionRecipients(cfg: OpenClawConfig) {
|
|
16
|
+
const sessionCfg = cfg.session;
|
|
17
|
+
const scope = sessionCfg?.scope ?? "per-sender";
|
|
18
|
+
if (scope === "global") {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const storePath = resolveStorePath(cfg.session?.store);
|
|
22
|
+
const store = loadSessionStore(storePath);
|
|
23
|
+
const isGroupKey = (key: string) =>
|
|
24
|
+
key.includes(":group:") || key.includes(":channel:") || key.includes("@g.us");
|
|
25
|
+
const isCronKey = (key: string) => key.startsWith("cron:");
|
|
26
|
+
|
|
27
|
+
const recipients = Object.entries(store)
|
|
28
|
+
.filter(([key]) => key !== "global" && key !== "unknown")
|
|
29
|
+
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
|
|
30
|
+
.map(([_, entry]) => ({
|
|
31
|
+
to:
|
|
32
|
+
normalizeChannelId(entry?.lastChannel) === "whatsapp" && entry?.lastTo
|
|
33
|
+
? normalizeE164(entry.lastTo)
|
|
34
|
+
: "",
|
|
35
|
+
updatedAt: entry?.updatedAt ?? 0,
|
|
36
|
+
}))
|
|
37
|
+
.filter(({ to }) => to.length > 1)
|
|
38
|
+
.toSorted((a, b) => b.updatedAt - a.updatedAt);
|
|
39
|
+
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
return recipients.filter((recipient) => {
|
|
42
|
+
if (seen.has(recipient.to)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
seen.add(recipient.to);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveWhatsAppHeartbeatRecipients(
|
|
51
|
+
cfg: OpenClawConfig,
|
|
52
|
+
opts: HeartbeatRecipientsOpts = {},
|
|
53
|
+
): HeartbeatRecipientsResult {
|
|
54
|
+
if (opts.to) {
|
|
55
|
+
return { recipients: [normalizeE164(opts.to)], source: "flag" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sessionRecipients = getSessionRecipients(cfg);
|
|
59
|
+
const resolvedAccountId =
|
|
60
|
+
opts.accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg) || DEFAULT_ACCOUNT_ID;
|
|
61
|
+
const configuredAllowFrom = (
|
|
62
|
+
resolveWhatsAppAccount({ cfg, accountId: resolvedAccountId }).allowFrom ?? []
|
|
63
|
+
)
|
|
64
|
+
.filter((value) => value !== "*")
|
|
65
|
+
.map(normalizeE164);
|
|
66
|
+
const storeAllowFrom = readChannelAllowFromStoreSync(
|
|
67
|
+
"whatsapp",
|
|
68
|
+
process.env,
|
|
69
|
+
resolvedAccountId,
|
|
70
|
+
).map(normalizeE164);
|
|
71
|
+
|
|
72
|
+
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
|
73
|
+
const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]);
|
|
74
|
+
|
|
75
|
+
if (opts.all) {
|
|
76
|
+
return {
|
|
77
|
+
recipients: unique([...sessionRecipients.map((entry) => entry.to), ...allowFrom]),
|
|
78
|
+
source: "all",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (allowFrom.length > 0) {
|
|
83
|
+
const allowSet = new Set(allowFrom);
|
|
84
|
+
const authorizedSessionRecipients = sessionRecipients
|
|
85
|
+
.map((entry) => entry.to)
|
|
86
|
+
.filter((recipient) => allowSet.has(recipient));
|
|
87
|
+
if (authorizedSessionRecipients.length === 1) {
|
|
88
|
+
return { recipients: [authorizedSessionRecipients[0]], source: "session-single" };
|
|
89
|
+
}
|
|
90
|
+
if (authorizedSessionRecipients.length > 1) {
|
|
91
|
+
return { recipients: authorizedSessionRecipients, source: "session-ambiguous" };
|
|
92
|
+
}
|
|
93
|
+
return { recipients: allowFrom, source: "allowFrom" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sessionRecipients.length === 1) {
|
|
97
|
+
return { recipients: [sessionRecipients[0].to], source: "session-single" };
|
|
98
|
+
}
|
|
99
|
+
if (sessionRecipients.length > 1) {
|
|
100
|
+
return { recipients: sessionRecipients.map((entry) => entry.to), source: "session-ambiguous" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { recipients: allowFrom, source: "allowFrom" };
|
|
104
|
+
}
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveWhatsAppAccount } from "./accounts.js";
|
|
2
|
+
import { readWebAuthExistsForDecision, WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js";
|
|
3
|
+
import type { OpenClawConfig } from "./runtime-api.js";
|
|
4
|
+
import { loadWhatsAppChannelRuntime } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
export async function checkWhatsAppHeartbeatReady(params: {
|
|
7
|
+
cfg: OpenClawConfig;
|
|
8
|
+
accountId?: string;
|
|
9
|
+
deps?: {
|
|
10
|
+
readWebAuthExistsForDecision?: typeof readWebAuthExistsForDecision;
|
|
11
|
+
hasActiveWebListener?: (accountId?: string) => boolean;
|
|
12
|
+
};
|
|
13
|
+
}) {
|
|
14
|
+
if (params.cfg.web?.enabled === false) {
|
|
15
|
+
return { ok: false as const, reason: "whatsapp-disabled" as const };
|
|
16
|
+
}
|
|
17
|
+
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
18
|
+
const authState = await (
|
|
19
|
+
params.deps?.readWebAuthExistsForDecision ?? readWebAuthExistsForDecision
|
|
20
|
+
)(account.authDir);
|
|
21
|
+
if (authState.outcome === "unstable") {
|
|
22
|
+
return { ok: false as const, reason: WHATSAPP_AUTH_UNSTABLE_CODE };
|
|
23
|
+
}
|
|
24
|
+
if (!authState.exists) {
|
|
25
|
+
return { ok: false as const, reason: "whatsapp-not-linked" as const };
|
|
26
|
+
}
|
|
27
|
+
const listenerActive = params.deps?.hasActiveWebListener
|
|
28
|
+
? params.deps.hasActiveWebListener(account.accountId)
|
|
29
|
+
: Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener(account.accountId));
|
|
30
|
+
if (!listenerActive) {
|
|
31
|
+
return { ok: false as const, reason: "whatsapp-not-running" as const };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true as const, reason: "ok" as const };
|
|
34
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { jidToE164, normalizeE164 } from "./text-runtime.js";
|
|
2
|
+
|
|
3
|
+
const WHATSAPP_LID_RE = /@(lid|hosted\.lid)$/i;
|
|
4
|
+
|
|
5
|
+
export type WhatsAppIdentity = {
|
|
6
|
+
jid?: string | null;
|
|
7
|
+
lid?: string | null;
|
|
8
|
+
e164?: string | null;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
label?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WhatsAppSelfIdentity = {
|
|
14
|
+
jid?: string | null;
|
|
15
|
+
lid?: string | null;
|
|
16
|
+
e164?: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WhatsAppReplyContext = {
|
|
20
|
+
id?: string;
|
|
21
|
+
body: string;
|
|
22
|
+
sender?: WhatsAppIdentity | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type LegacySenderLike = {
|
|
26
|
+
sender?: WhatsAppIdentity;
|
|
27
|
+
senderJid?: string;
|
|
28
|
+
senderE164?: string;
|
|
29
|
+
senderName?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type LegacySelfLike = {
|
|
33
|
+
self?: WhatsAppSelfIdentity;
|
|
34
|
+
selfJid?: string | null;
|
|
35
|
+
selfLid?: string | null;
|
|
36
|
+
selfE164?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type LegacyReplyLike = {
|
|
40
|
+
replyTo?: WhatsAppReplyContext;
|
|
41
|
+
replyToId?: string;
|
|
42
|
+
replyToBody?: string;
|
|
43
|
+
replyToSender?: string;
|
|
44
|
+
replyToSenderJid?: string;
|
|
45
|
+
replyToSenderE164?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type LegacyMentionsLike = {
|
|
49
|
+
mentions?: string[];
|
|
50
|
+
mentionedJids?: string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function normalizeDeviceScopedJid(jid: string | null | undefined): string | null {
|
|
54
|
+
return jid ? jid.replace(/:\d+/, "") : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isLidJid(jid: string | null | undefined): boolean {
|
|
58
|
+
return Boolean(jid && WHATSAPP_LID_RE.test(jid));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveComparableIdentity(
|
|
62
|
+
identity: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
|
|
63
|
+
authDir?: string,
|
|
64
|
+
): WhatsAppIdentity {
|
|
65
|
+
const rawJid = normalizeDeviceScopedJid(identity?.jid);
|
|
66
|
+
const rawLid = normalizeDeviceScopedJid(identity?.lid);
|
|
67
|
+
const lid = rawLid ?? (isLidJid(rawJid) ? rawJid : null);
|
|
68
|
+
const jid = rawJid && !isLidJid(rawJid) ? rawJid : null;
|
|
69
|
+
const e164 =
|
|
70
|
+
identity?.e164 != null
|
|
71
|
+
? normalizeE164(identity.e164)
|
|
72
|
+
: ((jid ? jidToE164(jid, authDir ? { authDir } : undefined) : null) ??
|
|
73
|
+
(lid ? jidToE164(lid, authDir ? { authDir } : undefined) : null));
|
|
74
|
+
return {
|
|
75
|
+
...identity,
|
|
76
|
+
jid,
|
|
77
|
+
lid,
|
|
78
|
+
e164,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getComparableIdentityValues(
|
|
83
|
+
identity: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
|
|
84
|
+
): string[] {
|
|
85
|
+
const resolved = resolveComparableIdentity(identity);
|
|
86
|
+
return [resolved.e164, resolved.jid, resolved.lid].filter((value): value is string =>
|
|
87
|
+
Boolean(value),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function identitiesOverlap(
|
|
92
|
+
left: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
|
|
93
|
+
right: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
|
|
94
|
+
): boolean {
|
|
95
|
+
const leftValues = new Set(getComparableIdentityValues(left));
|
|
96
|
+
if (leftValues.size === 0) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return getComparableIdentityValues(right).some((value) => leftValues.has(value));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getSenderIdentity(msg: LegacySenderLike, authDir?: string): WhatsAppIdentity {
|
|
103
|
+
return resolveComparableIdentity(
|
|
104
|
+
msg.sender ?? {
|
|
105
|
+
jid: msg.senderJid ?? null,
|
|
106
|
+
e164: msg.senderE164 ?? null,
|
|
107
|
+
name: msg.senderName ?? null,
|
|
108
|
+
},
|
|
109
|
+
authDir,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getSelfIdentity(msg: LegacySelfLike, authDir?: string): WhatsAppSelfIdentity {
|
|
114
|
+
return resolveComparableIdentity(
|
|
115
|
+
msg.self ?? {
|
|
116
|
+
jid: msg.selfJid ?? null,
|
|
117
|
+
lid: msg.selfLid ?? null,
|
|
118
|
+
e164: msg.selfE164 ?? null,
|
|
119
|
+
},
|
|
120
|
+
authDir,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getReplyContext(
|
|
125
|
+
msg: LegacyReplyLike,
|
|
126
|
+
authDir?: string,
|
|
127
|
+
): WhatsAppReplyContext | null {
|
|
128
|
+
if (msg.replyTo) {
|
|
129
|
+
return {
|
|
130
|
+
...msg.replyTo,
|
|
131
|
+
sender: resolveComparableIdentity(msg.replyTo.sender, authDir),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!msg.replyToBody) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
id: msg.replyToId,
|
|
139
|
+
body: msg.replyToBody,
|
|
140
|
+
sender: resolveComparableIdentity(
|
|
141
|
+
{
|
|
142
|
+
jid: msg.replyToSenderJid ?? null,
|
|
143
|
+
e164: msg.replyToSenderE164 ?? null,
|
|
144
|
+
label: msg.replyToSender ?? null,
|
|
145
|
+
},
|
|
146
|
+
authDir,
|
|
147
|
+
),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getMentionJids(msg: LegacyMentionsLike): string[] {
|
|
152
|
+
return msg.mentions ?? msg.mentionedJids ?? [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getMentionIdentities(
|
|
156
|
+
msg: LegacyMentionsLike,
|
|
157
|
+
authDir?: string,
|
|
158
|
+
): WhatsAppIdentity[] {
|
|
159
|
+
return getMentionJids(msg).map((jid) => resolveComparableIdentity({ jid }, authDir));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getPrimaryIdentityId(identity: WhatsAppIdentity | null | undefined): string | null {
|
|
163
|
+
return identity?.e164 || identity?.jid?.trim() || identity?.lid || null;
|
|
164
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { beforeEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type AsyncMock,
|
|
4
|
+
loadConfigMock,
|
|
5
|
+
readAllowFromStoreMock,
|
|
6
|
+
resetPairingSecurityMocks,
|
|
7
|
+
upsertPairingRequestMock,
|
|
8
|
+
} from "../pairing-security.test-harness.js";
|
|
9
|
+
|
|
10
|
+
export const sendMessageMock = vi.fn() as AsyncMock;
|
|
11
|
+
export { readAllowFromStoreMock, upsertPairingRequestMock };
|
|
12
|
+
|
|
13
|
+
let config: Record<string, unknown> = {};
|
|
14
|
+
|
|
15
|
+
export function setAccessControlTestConfig(next: Record<string, unknown>): void {
|
|
16
|
+
config = next;
|
|
17
|
+
loadConfigMock.mockReturnValue(config);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getAccessControlTestConfig(): Record<string, unknown> {
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setupAccessControlTestHarness(): void {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
config = {
|
|
27
|
+
channels: {
|
|
28
|
+
whatsapp: {
|
|
29
|
+
dmPolicy: "pairing",
|
|
30
|
+
allowFrom: [],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
sendMessageMock.mockReset().mockResolvedValue(undefined);
|
|
35
|
+
resetPairingSecurityMocks(config);
|
|
36
|
+
});
|
|
37
|
+
}
|