@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,330 @@
|
|
|
1
|
+
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
|
2
|
+
import { newConnectionId } from "../reconnect.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
|
5
|
+
HEARTBEAT_TOKEN,
|
|
6
|
+
appendCronStyleCurrentTimeLine,
|
|
7
|
+
canonicalizeMainSessionAlias,
|
|
8
|
+
emitHeartbeatEvent,
|
|
9
|
+
formatError,
|
|
10
|
+
getRuntimeConfig,
|
|
11
|
+
getChildLogger,
|
|
12
|
+
getReplyFromConfig,
|
|
13
|
+
hasOutboundReplyContent,
|
|
14
|
+
loadSessionStore,
|
|
15
|
+
normalizeMainKey,
|
|
16
|
+
redactIdentifier,
|
|
17
|
+
resolveHeartbeatPrompt,
|
|
18
|
+
resolveHeartbeatReplyPayload,
|
|
19
|
+
resolveHeartbeatVisibility,
|
|
20
|
+
resolveIndicatorType,
|
|
21
|
+
resolveSendableOutboundReplyParts,
|
|
22
|
+
resolveSessionKey,
|
|
23
|
+
resolveStorePath,
|
|
24
|
+
resolveWhatsAppHeartbeatRecipients,
|
|
25
|
+
sendMessageWhatsApp,
|
|
26
|
+
stripHeartbeatToken,
|
|
27
|
+
updateSessionStore,
|
|
28
|
+
whatsappHeartbeatLog,
|
|
29
|
+
} from "./heartbeat-runner.runtime.js";
|
|
30
|
+
import { getSessionSnapshot } from "./session-snapshot.js";
|
|
31
|
+
|
|
32
|
+
function resolveDefaultAgentIdFromConfig(cfg: ReturnType<typeof getRuntimeConfig>): string {
|
|
33
|
+
const agents = cfg.agents?.list ?? [];
|
|
34
|
+
const chosen = agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? "main";
|
|
35
|
+
return normalizeOptionalLowercaseString(chosen) ?? "main";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runWebHeartbeatOnce(opts: {
|
|
39
|
+
cfg?: ReturnType<typeof getRuntimeConfig>;
|
|
40
|
+
to: string;
|
|
41
|
+
verbose?: boolean;
|
|
42
|
+
replyResolver?: typeof getReplyFromConfig;
|
|
43
|
+
sender?: typeof sendMessageWhatsApp;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
overrideBody?: string;
|
|
46
|
+
dryRun?: boolean;
|
|
47
|
+
}) {
|
|
48
|
+
const { cfg: cfgOverride, to, verbose = false, sessionId, overrideBody, dryRun = false } = opts;
|
|
49
|
+
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
|
50
|
+
const sender = opts.sender ?? sendMessageWhatsApp;
|
|
51
|
+
const runId = newConnectionId();
|
|
52
|
+
const redactedTo = redactIdentifier(to);
|
|
53
|
+
const heartbeatLogger = getChildLogger({
|
|
54
|
+
module: "web-heartbeat",
|
|
55
|
+
runId,
|
|
56
|
+
to: redactedTo,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const cfg = cfgOverride ?? getRuntimeConfig();
|
|
60
|
+
|
|
61
|
+
// Resolve heartbeat visibility settings for WhatsApp
|
|
62
|
+
const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" });
|
|
63
|
+
const heartbeatOkText = HEARTBEAT_TOKEN;
|
|
64
|
+
|
|
65
|
+
const maybeSendHeartbeatOk = async (): Promise<boolean> => {
|
|
66
|
+
if (!visibility.showOk) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (dryRun) {
|
|
70
|
+
whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const sendResult = await sender(to, heartbeatOkText, { verbose, cfg });
|
|
74
|
+
heartbeatLogger.info(
|
|
75
|
+
{
|
|
76
|
+
to: redactedTo,
|
|
77
|
+
messageId: sendResult.messageId,
|
|
78
|
+
chars: heartbeatOkText.length,
|
|
79
|
+
reason: "heartbeat-ok",
|
|
80
|
+
},
|
|
81
|
+
"heartbeat ok sent",
|
|
82
|
+
);
|
|
83
|
+
whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`);
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const sessionCfg = cfg.session;
|
|
88
|
+
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
|
89
|
+
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
|
90
|
+
// Canonicalize so the written key matches what read paths produce (#29683).
|
|
91
|
+
const rawSessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
|
92
|
+
const sessionKey = canonicalizeMainSessionAlias({
|
|
93
|
+
cfg,
|
|
94
|
+
agentId: resolveDefaultAgentIdFromConfig(cfg),
|
|
95
|
+
sessionKey: rawSessionKey,
|
|
96
|
+
});
|
|
97
|
+
if (sessionId) {
|
|
98
|
+
const storePath = resolveStorePath(cfg.session?.store);
|
|
99
|
+
const store = loadSessionStore(storePath);
|
|
100
|
+
const current = store[sessionKey] ?? {};
|
|
101
|
+
store[sessionKey] = {
|
|
102
|
+
...current,
|
|
103
|
+
sessionId,
|
|
104
|
+
updatedAt: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
await updateSessionStore(storePath, (nextStore) => {
|
|
107
|
+
const nextCurrent = nextStore[sessionKey] ?? current;
|
|
108
|
+
nextStore[sessionKey] = {
|
|
109
|
+
...nextCurrent,
|
|
110
|
+
sessionId,
|
|
111
|
+
updatedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const sessionSnapshot = getSessionSnapshot(cfg, to, true, { sessionKey });
|
|
116
|
+
if (verbose) {
|
|
117
|
+
heartbeatLogger.info(
|
|
118
|
+
{
|
|
119
|
+
to: redactedTo,
|
|
120
|
+
sessionKey: sessionSnapshot.key,
|
|
121
|
+
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
|
|
122
|
+
sessionFresh: sessionSnapshot.fresh,
|
|
123
|
+
resetMode: sessionSnapshot.resetPolicy.mode,
|
|
124
|
+
resetAtHour: sessionSnapshot.resetPolicy.atHour,
|
|
125
|
+
idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null,
|
|
126
|
+
dailyResetAt: sessionSnapshot.dailyResetAt ?? null,
|
|
127
|
+
idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null,
|
|
128
|
+
},
|
|
129
|
+
"heartbeat session snapshot",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (overrideBody && overrideBody.trim().length === 0) {
|
|
134
|
+
throw new Error("Override body must be non-empty when provided.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (overrideBody) {
|
|
139
|
+
if (dryRun) {
|
|
140
|
+
whatsappHeartbeatLog.info(
|
|
141
|
+
`[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`,
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const sendResult = await sender(to, overrideBody, { verbose, cfg });
|
|
146
|
+
emitHeartbeatEvent({
|
|
147
|
+
status: "sent",
|
|
148
|
+
to,
|
|
149
|
+
preview: overrideBody.slice(0, 160),
|
|
150
|
+
hasMedia: false,
|
|
151
|
+
channel: "whatsapp",
|
|
152
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
|
153
|
+
});
|
|
154
|
+
heartbeatLogger.info(
|
|
155
|
+
{
|
|
156
|
+
to: redactedTo,
|
|
157
|
+
messageId: sendResult.messageId,
|
|
158
|
+
chars: overrideBody.length,
|
|
159
|
+
reason: "manual-message",
|
|
160
|
+
},
|
|
161
|
+
"manual heartbeat message sent",
|
|
162
|
+
);
|
|
163
|
+
whatsappHeartbeatLog.info(
|
|
164
|
+
`manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`,
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
|
|
170
|
+
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
|
|
171
|
+
emitHeartbeatEvent({
|
|
172
|
+
status: "skipped",
|
|
173
|
+
to,
|
|
174
|
+
reason: "alerts-disabled",
|
|
175
|
+
channel: "whatsapp",
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const replyResult = await replyResolver(
|
|
181
|
+
{
|
|
182
|
+
Body: appendCronStyleCurrentTimeLine(
|
|
183
|
+
resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
|
|
184
|
+
cfg,
|
|
185
|
+
Date.now(),
|
|
186
|
+
),
|
|
187
|
+
From: to,
|
|
188
|
+
To: to,
|
|
189
|
+
MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,
|
|
190
|
+
},
|
|
191
|
+
{ isHeartbeat: true },
|
|
192
|
+
cfg,
|
|
193
|
+
);
|
|
194
|
+
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
|
195
|
+
|
|
196
|
+
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
|
|
197
|
+
heartbeatLogger.info(
|
|
198
|
+
{
|
|
199
|
+
to: redactedTo,
|
|
200
|
+
reason: "empty-reply",
|
|
201
|
+
sessionId: sessionSnapshot.entry?.sessionId ?? null,
|
|
202
|
+
},
|
|
203
|
+
"heartbeat skipped",
|
|
204
|
+
);
|
|
205
|
+
const okSent = await maybeSendHeartbeatOk();
|
|
206
|
+
emitHeartbeatEvent({
|
|
207
|
+
status: "ok-empty",
|
|
208
|
+
to,
|
|
209
|
+
channel: "whatsapp",
|
|
210
|
+
silent: !okSent,
|
|
211
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const reply = resolveSendableOutboundReplyParts(replyPayload);
|
|
217
|
+
const hasMedia = reply.hasMedia;
|
|
218
|
+
const ackMaxChars = Math.max(
|
|
219
|
+
0,
|
|
220
|
+
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
|
221
|
+
);
|
|
222
|
+
const stripped = stripHeartbeatToken(replyPayload.text, {
|
|
223
|
+
mode: "heartbeat",
|
|
224
|
+
maxAckChars: ackMaxChars,
|
|
225
|
+
});
|
|
226
|
+
if (stripped.shouldSkip && !hasMedia) {
|
|
227
|
+
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
|
228
|
+
const storePath = resolveStorePath(cfg.session?.store);
|
|
229
|
+
const store = loadSessionStore(storePath);
|
|
230
|
+
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
|
231
|
+
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
|
232
|
+
await updateSessionStore(storePath, (nextStore) => {
|
|
233
|
+
const nextEntry = nextStore[sessionSnapshot.key];
|
|
234
|
+
if (!nextEntry) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
nextStore[sessionSnapshot.key] = {
|
|
238
|
+
...nextEntry,
|
|
239
|
+
updatedAt: sessionSnapshot.entry.updatedAt,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
heartbeatLogger.info(
|
|
245
|
+
{ to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
|
|
246
|
+
"heartbeat skipped",
|
|
247
|
+
);
|
|
248
|
+
const okSent = await maybeSendHeartbeatOk();
|
|
249
|
+
emitHeartbeatEvent({
|
|
250
|
+
status: "ok-token",
|
|
251
|
+
to,
|
|
252
|
+
channel: "whatsapp",
|
|
253
|
+
silent: !okSent,
|
|
254
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (hasMedia) {
|
|
260
|
+
heartbeatLogger.warn(
|
|
261
|
+
{ to: redactedTo },
|
|
262
|
+
"heartbeat reply contained media; sending text only",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const finalText = stripped.text || reply.text;
|
|
267
|
+
|
|
268
|
+
// Check if alerts are disabled for WhatsApp
|
|
269
|
+
if (!visibility.showAlerts) {
|
|
270
|
+
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
|
|
271
|
+
emitHeartbeatEvent({
|
|
272
|
+
status: "skipped",
|
|
273
|
+
to,
|
|
274
|
+
reason: "alerts-disabled",
|
|
275
|
+
preview: finalText.slice(0, 200),
|
|
276
|
+
channel: "whatsapp",
|
|
277
|
+
hasMedia,
|
|
278
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (dryRun) {
|
|
284
|
+
heartbeatLogger.info(
|
|
285
|
+
{ to: redactedTo, reason: "dry-run", chars: finalText.length },
|
|
286
|
+
"heartbeat dry-run",
|
|
287
|
+
);
|
|
288
|
+
whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const sendResult = await sender(to, finalText, { verbose, cfg });
|
|
293
|
+
emitHeartbeatEvent({
|
|
294
|
+
status: "sent",
|
|
295
|
+
to,
|
|
296
|
+
preview: finalText.slice(0, 160),
|
|
297
|
+
hasMedia,
|
|
298
|
+
channel: "whatsapp",
|
|
299
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
|
300
|
+
});
|
|
301
|
+
heartbeatLogger.info(
|
|
302
|
+
{
|
|
303
|
+
to: redactedTo,
|
|
304
|
+
messageId: sendResult.messageId,
|
|
305
|
+
chars: finalText.length,
|
|
306
|
+
},
|
|
307
|
+
"heartbeat sent",
|
|
308
|
+
);
|
|
309
|
+
whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const reason = formatError(err);
|
|
312
|
+
heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed");
|
|
313
|
+
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
|
|
314
|
+
emitHeartbeatEvent({
|
|
315
|
+
status: "failed",
|
|
316
|
+
to,
|
|
317
|
+
reason,
|
|
318
|
+
channel: "whatsapp",
|
|
319
|
+
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
|
|
320
|
+
});
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function resolveHeartbeatRecipients(
|
|
326
|
+
cfg: ReturnType<typeof getRuntimeConfig>,
|
|
327
|
+
opts: { to?: string; all?: boolean; accountId?: string } = {},
|
|
328
|
+
) {
|
|
329
|
+
return resolveWhatsAppHeartbeatRecipients(cfg, opts);
|
|
330
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
2
|
+
|
|
3
|
+
export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp");
|
|
4
|
+
export const whatsappInboundLog = whatsappLog.child("inbound");
|
|
5
|
+
export const whatsappOutboundLog = whatsappLog.child("outbound");
|
|
6
|
+
export const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildMentionRegexes,
|
|
3
|
+
normalizeMentionText,
|
|
4
|
+
} from "openclaw/plugin-sdk/channel-mention-gating";
|
|
5
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
6
|
+
import {
|
|
7
|
+
getComparableIdentityValues,
|
|
8
|
+
getMentionIdentities,
|
|
9
|
+
getSelfIdentity,
|
|
10
|
+
identitiesOverlap,
|
|
11
|
+
type WhatsAppIdentity,
|
|
12
|
+
} from "../identity.js";
|
|
13
|
+
import { isWhatsAppGroupJid } from "../normalize-target.js";
|
|
14
|
+
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
|
|
15
|
+
import type { WebInboundMsg } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export type MentionConfig = {
|
|
18
|
+
mentionRegexes: RegExp[];
|
|
19
|
+
allowFrom?: Array<string | number>;
|
|
20
|
+
isSelfChat?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MentionTargets = {
|
|
24
|
+
normalizedMentions: WhatsAppIdentity[];
|
|
25
|
+
self: WhatsAppIdentity;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function buildMentionConfig(cfg: OpenClawConfig, agentId?: string): MentionConfig {
|
|
29
|
+
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
|
30
|
+
return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveMentionTargets(msg: WebInboundMsg, authDir?: string): MentionTargets {
|
|
34
|
+
const normalizedMentions = getMentionIdentities(msg, authDir);
|
|
35
|
+
const self = getSelfIdentity(msg, authDir);
|
|
36
|
+
return { normalizedMentions, self };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isBotMentionedFromTargets(
|
|
40
|
+
msg: WebInboundMsg,
|
|
41
|
+
mentionCfg: MentionConfig,
|
|
42
|
+
targets: MentionTargets,
|
|
43
|
+
): boolean {
|
|
44
|
+
const clean = (text: string) =>
|
|
45
|
+
// Remove zero-width and directionality markers WhatsApp injects around display names
|
|
46
|
+
normalizeMentionText(text);
|
|
47
|
+
|
|
48
|
+
const explicitSelfChatOverride = typeof mentionCfg.isSelfChat === "boolean";
|
|
49
|
+
// `isSelfChatMode` is a config-shaped check ("is the bot's own E.164 in
|
|
50
|
+
// allowFrom?"), not a conversation-shaped check, so it returns true even
|
|
51
|
+
// for group conversations whenever the operator put their own number in
|
|
52
|
+
// allowFrom — which is the common config. The original mention-skip path
|
|
53
|
+
// was designed to prevent owner-mentioning-self in a true 1:1 self DM
|
|
54
|
+
// from falsely triggering the bot, so when we derive the flag implicitly
|
|
55
|
+
// from `allowFrom`, confine the suppression to non-group conversations
|
|
56
|
+
// and let real group @mentions go through the identity-overlap check
|
|
57
|
+
// (#49317). Explicit `mentionCfg.isSelfChat` overrides from the caller
|
|
58
|
+
// are honored as-is so multi-account / precomputed paths keep working.
|
|
59
|
+
const isGroupConversation = isWhatsAppGroupJid(msg.from);
|
|
60
|
+
const isSelfChat = explicitSelfChatOverride
|
|
61
|
+
? Boolean(mentionCfg.isSelfChat)
|
|
62
|
+
: isSelfChatMode(targets.self.e164, mentionCfg.allowFrom) && !isGroupConversation;
|
|
63
|
+
|
|
64
|
+
const hasMentions = targets.normalizedMentions.length > 0;
|
|
65
|
+
if (hasMentions && !isSelfChat) {
|
|
66
|
+
for (const mention of targets.normalizedMentions) {
|
|
67
|
+
if (identitiesOverlap(targets.self, mention)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// If the message explicitly mentions someone else, do not fall back to regex matches.
|
|
72
|
+
return false;
|
|
73
|
+
} else if (hasMentions && isSelfChat) {
|
|
74
|
+
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in self-chat triggers the bot.
|
|
75
|
+
}
|
|
76
|
+
const bodyClean = clean(msg.body);
|
|
77
|
+
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback: detect body containing our own number (with or without +, spacing)
|
|
82
|
+
if (targets.self.e164) {
|
|
83
|
+
const selfDigits = targets.self.e164.replace(/\D/g, "");
|
|
84
|
+
if (selfDigits) {
|
|
85
|
+
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
|
|
86
|
+
if (bodyDigits.includes(selfDigits)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const bodyNoSpace = msg.body.replace(/[\s-]/g, "");
|
|
90
|
+
const pattern = new RegExp(`\\+?${selfDigits}`, "i");
|
|
91
|
+
if (pattern.test(bodyNoSpace)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function debugMention(
|
|
101
|
+
msg: WebInboundMsg,
|
|
102
|
+
mentionCfg: MentionConfig,
|
|
103
|
+
authDir?: string,
|
|
104
|
+
): { wasMentioned: boolean; details: Record<string, unknown> } {
|
|
105
|
+
const mentionTargets = resolveMentionTargets(msg, authDir);
|
|
106
|
+
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
|
|
107
|
+
const details = {
|
|
108
|
+
from: msg.from,
|
|
109
|
+
body: msg.body,
|
|
110
|
+
bodyClean: normalizeMentionText(msg.body),
|
|
111
|
+
mentionedJids: msg.mentions ?? msg.mentionedJids ?? null,
|
|
112
|
+
normalizedMentionedJids: mentionTargets.normalizedMentions.length
|
|
113
|
+
? mentionTargets.normalizedMentions.map((identity) => getComparableIdentityValues(identity))
|
|
114
|
+
: null,
|
|
115
|
+
selfJid: msg.self?.jid ?? msg.selfJid ?? null,
|
|
116
|
+
selfLid: msg.self?.lid ?? msg.selfLid ?? null,
|
|
117
|
+
selfE164: msg.self?.e164 ?? msg.selfE164 ?? null,
|
|
118
|
+
resolvedSelf: mentionTargets.self,
|
|
119
|
+
};
|
|
120
|
+
return { wasMentioned: result, details };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveOwnerList(mentionCfg: MentionConfig, selfE164?: string | null) {
|
|
124
|
+
const allowFrom = mentionCfg.allowFrom;
|
|
125
|
+
const raw =
|
|
126
|
+
Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : selfE164 ? [selfE164] : [];
|
|
127
|
+
return raw
|
|
128
|
+
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
|
129
|
+
.map((entry) => normalizeE164(entry))
|
|
130
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
|
4
|
+
import type { WebInboundMessage } from "../../inbound/types.js";
|
|
5
|
+
import { maybeSendAckReaction } from "./ack-reaction.js";
|
|
6
|
+
|
|
7
|
+
const hoisted = vi.hoisted(() => ({
|
|
8
|
+
sendReactionWhatsApp: vi.fn(async () => undefined),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../../send.js", () => ({
|
|
12
|
+
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
|
16
|
+
return {
|
|
17
|
+
kind,
|
|
18
|
+
messageId: id,
|
|
19
|
+
messageIds: [id],
|
|
20
|
+
keys: [{ id }],
|
|
21
|
+
providerAccepted: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
|
26
|
+
return {
|
|
27
|
+
id: "msg-1",
|
|
28
|
+
from: "15551234567",
|
|
29
|
+
conversationId: "15551234567",
|
|
30
|
+
to: "15559876543",
|
|
31
|
+
accountId: "default",
|
|
32
|
+
body: "hello",
|
|
33
|
+
chatType: "direct",
|
|
34
|
+
chatId: "15551234567@s.whatsapp.net",
|
|
35
|
+
sendComposing: async () => {},
|
|
36
|
+
reply: async () => acceptedSendResult("text", "r1"),
|
|
37
|
+
sendMedia: async () => acceptedSendResult("media", "m1"),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createConfig(
|
|
43
|
+
reactionLevel: "off" | "ack" | "minimal" | "extensive",
|
|
44
|
+
extras?: Partial<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>,
|
|
45
|
+
): OpenClawConfig {
|
|
46
|
+
return {
|
|
47
|
+
channels: {
|
|
48
|
+
whatsapp: {
|
|
49
|
+
reactionLevel,
|
|
50
|
+
ackReaction: {
|
|
51
|
+
emoji: "👀",
|
|
52
|
+
direct: true,
|
|
53
|
+
group: "mentions",
|
|
54
|
+
},
|
|
55
|
+
...extras,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
} as OpenClawConfig;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type AckReactionParams = Parameters<typeof maybeSendAckReaction>[0];
|
|
62
|
+
|
|
63
|
+
const runAckReaction = (overrides: Partial<AckReactionParams> = {}) =>
|
|
64
|
+
maybeSendAckReaction({
|
|
65
|
+
cfg: createConfig("ack"),
|
|
66
|
+
msg: createMessage(),
|
|
67
|
+
agentId: "agent",
|
|
68
|
+
sessionKey: "whatsapp:default:15551234567",
|
|
69
|
+
conversationId: "15551234567",
|
|
70
|
+
verbose: false,
|
|
71
|
+
accountId: "default",
|
|
72
|
+
info: vi.fn(),
|
|
73
|
+
warn: vi.fn(),
|
|
74
|
+
...overrides,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const expectAckReactionSent = (accountId: string) => {
|
|
78
|
+
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
|
|
79
|
+
"15551234567@s.whatsapp.net",
|
|
80
|
+
"msg-1",
|
|
81
|
+
"👀",
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
verbose: false,
|
|
84
|
+
fromMe: false,
|
|
85
|
+
accountId,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
describe("maybeSendAckReaction", () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.clearAllMocks();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each(["ack", "minimal", "extensive"] as const)(
|
|
96
|
+
"sends ack reactions when reactionLevel is %s",
|
|
97
|
+
async (reactionLevel) => {
|
|
98
|
+
const ackReaction = await runAckReaction({
|
|
99
|
+
cfg: createConfig(reactionLevel),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(ackReaction?.ackReactionValue).toBe("👀");
|
|
103
|
+
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
|
|
104
|
+
expectAckReactionSent("default");
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it("suppresses ack reactions when reactionLevel is off", async () => {
|
|
109
|
+
const ackReaction = await runAckReaction({
|
|
110
|
+
cfg: createConfig("off"),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(ackReaction).toBeNull();
|
|
114
|
+
expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses the active account reactionLevel override for ack gating", async () => {
|
|
118
|
+
const ackReaction = await runAckReaction({
|
|
119
|
+
cfg: createConfig("off", {
|
|
120
|
+
accounts: {
|
|
121
|
+
work: {
|
|
122
|
+
reactionLevel: "ack",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
msg: createMessage({
|
|
127
|
+
accountId: "work",
|
|
128
|
+
}),
|
|
129
|
+
sessionKey: "whatsapp:work:15551234567",
|
|
130
|
+
accountId: "work",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(ackReaction?.ackReactionValue).toBe("👀");
|
|
134
|
+
expectAckReactionSent("work");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns a handle that removes the ack with an empty reaction", async () => {
|
|
138
|
+
const ackReaction = await runAckReaction();
|
|
139
|
+
|
|
140
|
+
await ackReaction?.remove();
|
|
141
|
+
|
|
142
|
+
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
|
|
143
|
+
"15551234567@s.whatsapp.net",
|
|
144
|
+
"msg-1",
|
|
145
|
+
"",
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
verbose: false,
|
|
148
|
+
fromMe: false,
|
|
149
|
+
accountId: "default",
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("records ack send failures on the handle", async () => {
|
|
155
|
+
const warn = vi.fn();
|
|
156
|
+
hoisted.sendReactionWhatsApp.mockRejectedValueOnce(new Error("session down"));
|
|
157
|
+
|
|
158
|
+
const ackReaction = await runAckReaction({ warn });
|
|
159
|
+
|
|
160
|
+
await expect(ackReaction?.ackReactionPromise).resolves.toBe(false);
|
|
161
|
+
expect(warn).toHaveBeenCalledWith(
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
error: "session down",
|
|
164
|
+
chatId: "15551234567@s.whatsapp.net",
|
|
165
|
+
messageId: "msg-1",
|
|
166
|
+
}),
|
|
167
|
+
"failed to send ack reaction",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|