Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/publish-whatsapp-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,24 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: whatsapp-v${{ steps.pack.outputs.version }}
name: "@openclaw/whatsapp ${{ steps.pack.outputs.version }} (Clawnify fork + group/conversation tools)"
name: "@openclaw/whatsapp ${{ steps.pack.outputs.version }} (Clawnify fork)"
body: |
Clawnify fork of `@openclaw/whatsapp@${{ steps.pack.outputs.version }}` (built on upstream
`${{ steps.target.outputs.tag }}`) with opt-in agent tools:
- Group management (`whatsapp_group_create`, `whatsapp_group_add_participants`,
`whatsapp_group_invite_link`), gated behind `OPENCLAW_WHATSAPP_GROUPS=1`.
- Conversation read (`whatsapp_list_chats`, `whatsapp_read_conversation`), gated behind
`OPENCLAW_WHATSAPP_CONVERSATIONS=1`. In-memory mirror, RAM only, never persisted.
`${{ steps.target.outputs.tag }}`). The authoritative delta is
`clawnify-patches/whatsapp-clawnify-tools.patch` — always check the patch for the exact
current feature set, not this list (which can lag). At time of writing:
- Group management (`whatsapp_group_create` / `_add_participants` / `_invite_link`),
gate `OPENCLAW_WHATSAPP_GROUPS=1`.
- Conversation read (`whatsapp_list_chats` / `whatsapp_read_conversation`) with a
file-persistent mirror + on-demand history fetch, gate `OPENCLAW_WHATSAPP_CONVERSATIONS=1`.
- Profile tools (`whatsapp_get_profile` / `_set_profile_name` / `_set_profile_picture`),
gate `OPENCLAW_WHATSAPP_PROFILE=1`.
- Outbound to any number, opt-in via `plugins.entries.whatsapp.config.outboundOpen`
(default off; inbound stays gated by `dmPolicy`/`allowFrom`).

Install on a server running openclaw `${{ steps.pack.outputs.version }}`:
```
openclaw plugins install <this-asset-url> --force --pin
```
Interim until openclaw/openclaw#89033 lands.
files: ${{ steps.pack.outputs.tarball }}
fail_on_unmatched_files: true
280 changes: 259 additions & 21 deletions clawnify-patches/whatsapp-clawnify-tools.patch

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions extensions/whatsapp/src/channel-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,28 @@ describe("whatsappChannelOutbound", () => {
preserveLeadingWhitespace: true,
});
});

describe("resolveTarget outbound gate (channels.whatsapp.outboundOpen)", () => {
const target = "+15551234567";
const allowFrom = ["+15559999999"]; // does NOT include target
const cfgWith = (outboundOpen: boolean | undefined) =>
({ channels: { whatsapp: outboundOpen === undefined ? {} : { outboundOpen } } }) as unknown as Parameters<
NonNullable<typeof whatsappChannelOutbound.resolveTarget>
>[0]["cfg"];

it("blocks a non-allowlisted number by default (no cfg)", () => {
const res = whatsappChannelOutbound.resolveTarget?.({ to: target, allowFrom, mode: "explicit" });
expect(res?.ok).toBe(false);
});

it("allows any number when channels.whatsapp.outboundOpen is true", () => {
const res = whatsappChannelOutbound.resolveTarget?.({ to: target, allowFrom, cfg: cfgWith(true), mode: "explicit" });
expect(res?.ok).toBe(true);
});

it("still blocks when outboundOpen is false", () => {
const res = whatsappChannelOutbound.resolveTarget?.({ to: target, allowFrom, cfg: cfgWith(false), mode: "explicit" });
expect(res?.ok).toBe(false);
});
});
});
32 changes: 30 additions & 2 deletions extensions/whatsapp/src/channel-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
defineChannelMessageAdapter,
type ChannelMessageSendResult,
} from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
import { createWhatsAppOutboundBase } from "./outbound-base.js";
import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js";
Expand All @@ -19,6 +20,28 @@ function normalizeWhatsAppChannelSendText(text: string | undefined): string {
return normalized.trim() ? normalized : "";
}

/**
* Outbound target policy, read from `channels.whatsapp.outboundOpen` (a
* first-class channel setting alongside `dmPolicy`/`groupPolicy`/`allowFrom`,
* with the usual per-account override). When true the agent may send to any
* number; `dmPolicy`/`allowFrom` still gate who can trigger a reply, so inbound
* stays restricted. Default false (restricted). The field is declared in the
* WhatsApp config schema, so the plugin's generated `channelConfigs` schema
* validates it and the gateway accepts the config write.
*/
function resolveWhatsAppOutboundPolicy(
cfg: OpenClawConfig | undefined,
accountId: string | null | undefined,
): "allowlist" | "open" {
const wa = cfg?.channels?.whatsapp;
const account =
accountId && wa?.accounts && typeof wa.accounts === "object"
? wa.accounts[accountId]
: undefined;
const open = account?.outboundOpen ?? wa?.outboundOpen;
return open === true ? "open" : "allowlist";
}

export const whatsappChannelOutbound = {
...createWhatsAppOutboundBase({
chunker: chunkText,
Expand All @@ -29,8 +52,13 @@ export const whatsappChannelOutbound = {
}),
sendPollWhatsApp,
shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(),
resolveTarget: ({ to, allowFrom, mode }) =>
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
resolveTarget: ({ to, allowFrom, mode, cfg, accountId }) =>
resolveWhatsAppOutboundTarget({
to,
allowFrom,
mode,
outboundPolicy: resolveWhatsAppOutboundPolicy(cfg, accountId),
}),
normalizeText: normalizeWhatsAppChannelSendText,
}),
sendTextOnlyErrorPayloads: true,
Expand Down
41 changes: 41 additions & 0 deletions extensions/whatsapp/src/resolve-outbound-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,45 @@ describe("resolveWhatsAppOutboundTarget", () => {
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith(PRIMARY_TARGET);
});
});

describe("outboundPolicy", () => {
it('"open" allows a plain number that is not in allowFrom', () => {
// Only `to` is normalized: the open path returns before touching allowFrom.
mockNormalizedDirectMessage(PRIMARY_TARGET);
expectResolutionOk(
{
to: PRIMARY_TARGET,
allowFrom: [SECONDARY_TARGET],
mode: "implicit",
outboundPolicy: "open",
},
PRIMARY_TARGET,
);
});

it('"allowlist" still denies a plain number not in allowFrom', () => {
mockNormalizedDirectMessage(PRIMARY_TARGET, SECONDARY_TARGET);
expectResolutionErrorMessage(
{
to: PRIMARY_TARGET,
allowFrom: [SECONDARY_TARGET],
mode: "implicit",
outboundPolicy: "allowlist",
},
`Target "${PRIMARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
);
});

it("undefined outboundPolicy preserves the restrictive default (deny)", () => {
mockNormalizedDirectMessage(PRIMARY_TARGET, SECONDARY_TARGET);
expectResolutionErrorMessage(
{
to: PRIMARY_TARGET,
allowFrom: [SECONDARY_TARGET],
mode: "implicit",
},
`Target "${PRIMARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
);
});
});
});
13 changes: 13 additions & 0 deletions extensions/whatsapp/src/resolve-outbound-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export function resolveWhatsAppOutboundTarget(params: {
to: string | null | undefined;
allowFrom: Array<string | number> | null | undefined;
mode: string | null | undefined;
/**
* Outbound target policy, independent of the inbound `allowFrom` sender
* allowlist. "open" lets the agent send to any number; "allowlist"
* (default) restricts plain-number sends to `allowFrom`. Group/newsletter
* JIDs are always allowed regardless of this policy.
*/
outboundPolicy?: "allowlist" | "open" | null;
}): WhatsAppOutboundTargetResolution {
const trimmed = params.to?.trim() ?? "";
if (!trimmed) {
Expand All @@ -37,6 +44,12 @@ export function resolveWhatsAppOutboundTarget(params: {
return { ok: true, to: normalizedTo };
}

// "open" decouples outbound from the inbound allowlist: the agent may send to
// any number. Inbound reply authorization still runs through dmPolicy/allowFrom.
if (params.outboundPolicy === "open") {
return { ok: true, to: normalizedTo };
}

const allowListRaw = (params.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
Expand Down
4 changes: 2 additions & 2 deletions src/config/bundled-channel-config-metadata.generated.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/config/types.whatsapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ type WhatsAppSharedConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/**
* Outbound target authorization, independent of the inbound `allowFrom`
* allowlist. When true the agent may send to any number; dmPolicy/allowFrom
* still gate who can trigger a reply. Default false (restricted). Set via
* `plugins.entries.whatsapp.config.outboundOpen`.
*/
outboundOpen?: boolean;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Max group messages to keep as history context (0 disables). */
Expand Down
6 changes: 6 additions & 0 deletions src/config/zod-schema.providers-whatsapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
groupPolicy: params.useDefaults
? GroupPolicySchema.optional().default("allowlist")
: GroupPolicySchema.optional(),
// Outbound target authorization, independent of the inbound `allowFrom`
// sender allowlist. When true the agent may send to any number; dmPolicy/
// allowFrom still gate who can trigger a reply. Default false (restricted).
// Read from `plugins.entries.whatsapp.config.outboundOpen`; declared here so
// the plugin's generated config schema accepts (and the gateway validates) it.
outboundOpen: z.boolean().optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
Expand Down
Loading