Skip to content

Commit 5fbaf2a

Browse files
authored
feat(whatsapp): support thumb approval reactions (#85477)
* feat(whatsapp): support emoji approval reactions * fix(whatsapp): simplify approval resolved text * fix(whatsapp): gate approvals on forwarding config * ci: ignore injected secrets helpers in oxlint * fix(whatsapp): use thumb reactions for approvals * ci: keep secret helpers linted * fix(approvals): preserve plugin turn source routes * docs(approvals): remove whatsapp exec approval field refs
1 parent 6a3781d commit 5fbaf2a

28 files changed

Lines changed: 2948 additions & 50 deletions

docs/channels/whatsapp.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ handoff path over manual terminal capture.
175175
- WhatsApp Web transport honors standard proxy environment variables on the gateway host (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` / lowercase variants). Prefer host-level proxy config over channel-specific WhatsApp proxy settings.
176176
- When `messages.removeAckAfterReply` is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered.
177177

178+
## Approval prompts
179+
180+
WhatsApp can render exec and plugin approval prompts with `👍` / `👎` reactions. Delivery is
181+
controlled by the top-level approval forwarding config:
182+
183+
```json5
184+
{
185+
approvals: {
186+
exec: {
187+
enabled: true,
188+
mode: "session",
189+
},
190+
plugin: {
191+
enabled: true,
192+
mode: "targets",
193+
targets: [{ channel: "whatsapp", to: "+15551234567" }],
194+
},
195+
},
196+
}
197+
```
198+
199+
`approvals.exec` and `approvals.plugin` are independent. Enabling WhatsApp as a channel only links
200+
the transport; it does not send approval prompts unless the matching approval family is enabled
201+
and routes to WhatsApp. Session mode delivers native emoji approvals only for approvals that
202+
originate from WhatsApp. Target mode uses the shared forwarding pipeline for explicit WhatsApp
203+
targets and does not create separate approver-DM fanout.
204+
205+
WhatsApp approval reactions require explicit WhatsApp approvers from `allowFrom` or `"*"`.
206+
`defaultTo` controls ordinary default message targets; it is not an approval approver. Manual
207+
`/approve` commands still pass through the normal WhatsApp sender authorization path before
208+
approval resolution.
209+
178210
## Plugin hooks and privacy
179211

180212
WhatsApp inbound messages can contain personal message content, phone numbers,

docs/tools/exec-approvals-advanced.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ Generic model:
279279
- Slack plugin approvals can use Slack's native approval client when the request comes from Slack
280280
and Slack plugin approvers resolve; `approvals.plugin` can also route plugin approvals to Slack
281281
sessions or targets even when Slack exec approvals are disabled
282+
- WhatsApp emoji approval delivery is gated by `approvals.exec` and `approvals.plugin`, while
283+
approval reactions require explicit WhatsApp approvers from `channels.whatsapp.allowFrom` or `"*"`
282284

283285
Native approval clients auto-enable DM-first delivery when all of these are true:
284286

@@ -296,6 +298,7 @@ FAQ: [Why are there two exec approval configs for chat approvals?](/help/faq-fir
296298
- Discord: `channels.discord.execApprovals.*`
297299
- Slack: `channels.slack.execApprovals.*`
298300
- Telegram: `channels.telegram.execApprovals.*`
301+
- WhatsApp: use `approvals.exec` and `approvals.plugin` to route approval prompts to WhatsApp
299302

300303
These native approval clients add DM routing and optional channel fanout on top of the shared
301304
same-chat `/approve` flow and shared approval buttons.
@@ -313,6 +316,9 @@ Shared behavior:
313316
routing, not Slack exec approvers
314317
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
315318
without a second Slack-local fallback layer
319+
- WhatsApp emoji approvals handle both exec and plugin prompts only when the matching top-level
320+
forwarding family is enabled and routes to WhatsApp; target-only WhatsApp forwarding stays on
321+
the shared forwarding path unless it matches the same native origin target
316322
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
317323
plugin authorization still comes from `channels.matrix.dm.allowFrom`
318324
- Matrix native prompts include `com.openclaw.approval` custom event content on the first prompt
@@ -358,6 +364,70 @@ Security notes:
358364
- Same-UID peer check.
359365
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
360366

367+
## FAQ
368+
369+
### When would `accountId` and `threadId` be used on an approval target?
370+
371+
Use `accountId` when the channel has multiple configured identities and the approval prompt must
372+
leave through one specific account. Use `threadId` when the destination supports topics or
373+
threads and the prompt should stay inside that thread instead of the top-level chat.
374+
375+
A concrete Telegram case is an operations supergroup with forum topics and two Telegram bot
376+
accounts. The `to` value names the supergroup, `accountId` selects the bot account, and `threadId`
377+
selects the forum topic:
378+
379+
```json5
380+
{
381+
approvals: {
382+
exec: {
383+
enabled: true,
384+
mode: "targets",
385+
targets: [
386+
{
387+
channel: "telegram",
388+
to: "-1001234567890",
389+
accountId: "ops-bot",
390+
threadId: "77",
391+
},
392+
],
393+
},
394+
},
395+
channels: {
396+
telegram: {
397+
accounts: {
398+
default: {
399+
name: "Primary bot",
400+
botToken: "env:TELEGRAM_PRIMARY_BOT_TOKEN",
401+
},
402+
"ops-bot": {
403+
name: "Operations bot",
404+
botToken: "env:TELEGRAM_OPS_BOT_TOKEN",
405+
},
406+
},
407+
},
408+
},
409+
}
410+
```
411+
412+
With that setup, forwarded exec approvals are posted by the `ops-bot` Telegram account into topic
413+
`77` of chat `-1001234567890`. A target without `accountId` uses the channel's default account, and
414+
a target without `threadId` posts to the top-level destination.
415+
416+
### When approvals are sent to a session, can anyone in that session approve them?
417+
418+
No. Session delivery only controls where the prompt appears. It does not by itself authorize every
419+
participant in that chat to approve.
420+
421+
For generic same-chat `/approve`, the sender must already be authorized for commands in that
422+
channel session. If the channel exposes explicit approval approvers, those approvers can authorize
423+
the `/approve` action even when they are not otherwise command-authorized in that session.
424+
425+
Some channels are stricter. Discord, Telegram, Matrix, Slack native approval DMs, and similar
426+
native approval clients use their resolved approver lists for approval authorization. For example,
427+
a Telegram forum-topic approval prompt can be visible to everyone in the topic, but only numeric
428+
Telegram user IDs resolved from `channels.telegram.execApprovals.approvers` or
429+
`commands.ownerAllowFrom` can approve or deny it.
430+
361431
## Related
362432

363433
- [Exec approvals](/tools/exec-approvals) — core policy and approval flow
Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, expect, it } from "vitest";
2-
import { whatsappApprovalAuth } from "./approval-auth.js";
2+
import { getWhatsAppApprovalApprovers, whatsappApprovalAuth } from "./approval-auth.js";
33

44
describe("whatsappApprovalAuth", () => {
5-
it("authorizes direct WhatsApp recipients and ignores groups", () => {
5+
it("authorizes direct WhatsApp recipients and ignores group entries", () => {
66
expect(
77
whatsappApprovalAuth.authorizeActorAction({
88
cfg: { channels: { whatsapp: { allowFrom: ["+1 (555) 123-0000"] } } },
@@ -13,12 +13,49 @@ describe("whatsappApprovalAuth", () => {
1313
).toEqual({ authorized: true });
1414

1515
expect(
16-
whatsappApprovalAuth.authorizeActorAction({
16+
getWhatsAppApprovalApprovers({
1717
cfg: { channels: { whatsapp: { allowFrom: ["12345-67890@g.us"] } } },
18+
}),
19+
).toEqual([]);
20+
21+
expect(
22+
whatsappApprovalAuth.authorizeActorAction({
23+
cfg: { channels: { whatsapp: { allowFrom: ["+15551230000"] } } },
1824
senderId: "+15551239999",
1925
action: "approve",
2026
approvalKind: "exec",
2127
}),
28+
).toEqual({
29+
authorized: false,
30+
reason: "❌ You are not authorized to approve exec requests on WhatsApp.",
31+
});
32+
});
33+
34+
it("does not treat defaultTo as an explicit approval approver", () => {
35+
expect(
36+
getWhatsAppApprovalApprovers({
37+
cfg: { channels: { whatsapp: { allowFrom: [], defaultTo: "+15551230000" } } },
38+
}),
39+
).toEqual([]);
40+
41+
expect(
42+
whatsappApprovalAuth.authorizeActorAction({
43+
cfg: { channels: { whatsapp: { allowFrom: [], defaultTo: "+15551230000" } } },
44+
senderId: "+15551230000",
45+
action: "approve",
46+
approvalKind: "exec",
47+
}),
48+
).toEqual({ authorized: true });
49+
});
50+
51+
it("supports explicit wildcard approval approvers", () => {
52+
expect(
53+
whatsappApprovalAuth.authorizeActorAction({
54+
cfg: { channels: { whatsapp: { allowFrom: ["*"] } } },
55+
senderId: "+15551230000",
56+
action: "approve",
57+
approvalKind: "plugin",
58+
}),
2259
).toEqual({ authorized: true });
2360
});
2461
});

extensions/whatsapp/src/approval-auth.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,59 @@ import {
55
import { resolveWhatsAppAccount } from "./accounts.js";
66
import { normalizeWhatsAppTarget } from "./normalize.js";
77

8-
function normalizeWhatsAppApproverId(value: string | number): string | undefined {
8+
type ApprovalKind = "exec" | "plugin";
9+
10+
export function normalizeWhatsAppApproverId(value: string | number): string | undefined {
911
const normalized = normalizeWhatsAppTarget(String(value));
1012
if (!normalized || normalized.endsWith("@g.us")) {
1113
return undefined;
1214
}
1315
return normalized;
1416
}
1517

16-
export const whatsappApprovalAuth = createResolvedApproverActionAuthAdapter({
18+
function normalizeWhatsAppApproverEntry(value: string | number): string | undefined {
19+
return String(value).trim() === "*" ? "*" : normalizeWhatsAppApproverId(value);
20+
}
21+
22+
export function getWhatsAppApprovalApprovers(params: {
23+
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
24+
accountId?: string | null;
25+
}): string[] {
26+
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
27+
return resolveApprovalApprovers({
28+
allowFrom: account.allowFrom,
29+
normalizeApprover: normalizeWhatsAppApproverEntry,
30+
});
31+
}
32+
33+
const whatsappResolvedApproverAuth = createResolvedApproverActionAuthAdapter({
1734
channelLabel: "WhatsApp",
18-
resolveApprovers: ({ cfg, accountId }) => {
19-
const account = resolveWhatsAppAccount({ cfg, accountId });
20-
return resolveApprovalApprovers({
21-
allowFrom: account.allowFrom,
22-
defaultTo: account.defaultTo,
23-
normalizeApprover: normalizeWhatsAppApproverId,
24-
});
25-
},
35+
resolveApprovers: ({ cfg, accountId }) => getWhatsAppApprovalApprovers({ cfg, accountId }),
2636
normalizeSenderId: (value) => normalizeWhatsAppApproverId(value),
2737
});
38+
39+
export const whatsappApprovalAuth = {
40+
authorizeActorAction({
41+
cfg,
42+
accountId,
43+
senderId,
44+
approvalKind,
45+
}: {
46+
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
47+
accountId?: string | null;
48+
senderId?: string | null;
49+
action: "approve";
50+
approvalKind: ApprovalKind;
51+
}) {
52+
if (getWhatsAppApprovalApprovers({ cfg, accountId }).includes("*")) {
53+
return { authorized: true } as const;
54+
}
55+
return whatsappResolvedApproverAuth.authorizeActorAction({
56+
cfg,
57+
accountId,
58+
senderId,
59+
action: "approve",
60+
approvalKind,
61+
});
62+
},
63+
};

0 commit comments

Comments
 (0)