Skip to content

Commit 0fad53a

Browse files
vincentkocopenclaw-clownfish[bot]clawsweeper[bot]
authored
feat(whatsapp): support newsletter targets in message tool (#73393)
Summary: - Adds WhatsApp `@newsletter` target normalization, outbound allowFrom bypass, channel session routing, composing-presence suppression, docs/changelog updates, and focused tests. - Reproducibility: yes. Source inspection on current main shows a `120363401234567890@newsletter` target normalizes to null before outbound send, and the current session route has only direct/group semantics. ClawSweeper fixups: - Included follow-up commit: fix(clownfish): address review for ghcrawl-156943-autonomous-smoke (1) - Included follow-up commit: feat(whatsapp): support newsletter targets in message tool Validation: - ClawSweeper review passed for head 9ff3f88. - Required merge gates passed before the squash merge. Prepared head SHA: 9ff3f88 Review: #73393 (comment) Co-authored-by: vincentkoc <[email protected]> Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent a1dc8c0 commit 0fad53a

17 files changed

Lines changed: 198 additions & 40 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
1010
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
11+
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.
1112

1213
### Fixes
1314

docs/channels/whatsapp.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
158158
- The reconnect watchdog follows WhatsApp Web transport activity, not only inbound app-message volume: quiet linked-device sessions stay up while transport frames continue, but a transport stall forces reconnect well before the later remote disconnect path.
159159
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
160160
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
161+
- WhatsApp Channels/Newsletters can be explicit outbound targets with their native `@newsletter` JID. Outbound newsletter sends use channel session metadata (`agent:<agentId>:whatsapp:channel:<jid>`) rather than DM session semantics.
161162
- 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.
162163
- When `messages.removeAckAfterReply` is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered.
163164

@@ -214,6 +215,8 @@ content and identifiers.
214215

215216
`allowFrom` accepts E.164-style numbers (normalized internally).
216217

218+
`allowFrom` is a DM sender access-control list. It does not gate explicit outbound sends to WhatsApp group JIDs or `@newsletter` channel JIDs.
219+
217220
Multi-account override: `channels.whatsapp.accounts.<id>.dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account.
218221

219222
Runtime behavior details:

docs/cli/directory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ openclaw message send --channel slack --target user:U012ABCDEF --message "hello"
3232

3333
## ID formats (by channel)
3434

35-
- WhatsApp: `+15551234567` (DM), `[email protected]` (group)
35+
- WhatsApp: `+15551234567` (DM), `[email protected]` (group), `120363123456789@newsletter` (Channel/Newsletter outbound target)
3636
- Telegram: `@username` or numeric chat id; groups are numeric ids
3737
- Slack: `user:U…` and `channel:C…`
3838
- Discord: `user:<id>` and `channel:<id>`

docs/cli/message.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Channel selection:
2626

2727
Target formats (`--target`):
2828

29-
- WhatsApp: E.164 or group JID
29+
- WhatsApp: E.164, group JID, or WhatsApp Channel/Newsletter JID (`...@newsletter`)
3030
- Telegram: chat id or `@username`
3131
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
3232
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
@@ -76,7 +76,7 @@ Name lookup:
7676
- Telegram only: `--thread-id` (forum topic id)
7777
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
7878
- Telegram + Discord: `--silent`
79-
- WhatsApp only: `--gif-playback`
79+
- WhatsApp only: `--gif-playback`; WhatsApp Channels/Newsletters are addressed with their native `@newsletter` JID.
8080

8181
- `poll`
8282
- Channels: WhatsApp/Telegram/Discord/Matrix/Microsoft Teams

extensions/whatsapp/src/channel.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
2929
import {
3030
isWhatsAppGroupJid,
31+
isWhatsAppNewsletterJid,
3132
looksLikeWhatsAppTargetId,
3233
normalizeWhatsAppMessagingTarget,
3334
normalizeWhatsAppTarget,
@@ -56,7 +57,11 @@ function parseWhatsAppExplicitTarget(raw: string) {
5657
}
5758
return {
5859
to: normalized,
59-
chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const),
60+
chatType: isWhatsAppGroupJid(normalized)
61+
? ("group" as const)
62+
: isWhatsAppNewsletterJid(normalized)
63+
? ("channel" as const)
64+
: ("direct" as const),
6065
};
6166
}
6267

@@ -117,7 +122,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
117122
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
118123
targetResolver: {
119124
looksLikeId: looksLikeWhatsAppTargetId,
120-
hint: "<E.164|group JID>",
125+
hint: "<E.164|group JID|newsletter JID>",
121126
},
122127
},
123128
directory: {

extensions/whatsapp/src/inbound/send-api.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ describe("createWebSendApi", () => {
260260
expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "[email protected]");
261261
});
262262

263+
it("does not send composing presence to newsletter JIDs", async () => {
264+
await api.sendComposingTo("120363401234567890@newsletter");
265+
expect(sendPresenceUpdate).not.toHaveBeenCalled();
266+
});
267+
268+
it("preserves newsletter JIDs for outbound sends", async () => {
269+
await api.sendMessage("120363401234567890@newsletter", "hello");
270+
expect(sendMessage).toHaveBeenCalledWith("120363401234567890@newsletter", {
271+
text: "hello",
272+
});
273+
});
274+
263275
it("sends media as document when mediaType is undefined", async () => {
264276
const mediaBuffer = Buffer.from("test");
265277

extensions/whatsapp/src/inbound/send-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
WAPresence,
66
} from "@whiskeysockets/baileys";
77
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
8+
import { isWhatsAppNewsletterJid } from "../normalize.js";
89
import { buildQuotedMessageOptions } from "../quoted-message.js";
910
import { toWhatsappJid } from "../text-runtime.js";
1011
import {
@@ -135,6 +136,9 @@ export function createWebSendApi(params: {
135136
},
136137
sendComposingTo: async (to: string): Promise<void> => {
137138
const jid = toWhatsappJid(to);
139+
if (isWhatsAppNewsletterJid(jid)) {
140+
return;
141+
}
138142
await params.sock.sendPresenceUpdate("composing", jid);
139143
},
140144
} as const;

extensions/whatsapp/src/normalize-target.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i;
55
const WHATSAPP_LEGACY_USER_JID_RE = /^(\d+)@c\.us$/i;
66
const WHATSAPP_LID_RE = /^(\d+)@lid$/i;
77
const NON_WHATSAPP_PROVIDER_PREFIX_RE = /^[a-z][a-z0-9-]*:/i;
8+
const WHATSAPP_NEWSLETTER_JID_RE = /^([0-9]+)@newsletter$/i;
89

910
function stripWhatsAppTargetPrefixes(value: string): string {
1011
let candidate = value.trim();
@@ -30,6 +31,11 @@ export function isWhatsAppGroupJid(value: string): boolean {
3031
return /^[0-9]+(-[0-9]+)*$/.test(localPart);
3132
}
3233

34+
export function isWhatsAppNewsletterJid(value: string): boolean {
35+
const candidate = stripWhatsAppTargetPrefixes(value);
36+
return WHATSAPP_NEWSLETTER_JID_RE.test(candidate);
37+
}
38+
3339
export function isWhatsAppUserTarget(value: string): boolean {
3440
const candidate = stripWhatsAppTargetPrefixes(value);
3541
return (
@@ -64,6 +70,10 @@ export function normalizeWhatsAppTarget(value: string): string | null {
6470
const localPart = candidate.slice(0, candidate.length - "@g.us".length);
6571
return `${localPart}@g.us`;
6672
}
73+
if (isWhatsAppNewsletterJid(candidate)) {
74+
const match = candidate.match(WHATSAPP_NEWSLETTER_JID_RE);
75+
return match ? `${match[1]}@newsletter` : null;
76+
}
6777
if (isWhatsAppUserTarget(candidate)) {
6878
const phone = extractUserJidPhone(candidate);
6979
if (!phone) {
@@ -106,6 +116,7 @@ export function looksLikeWhatsAppTargetId(raw: string): boolean {
106116
return (
107117
/^whatsapp:/i.test(trimmed) ||
108118
isWhatsAppGroupJid(trimmed) ||
119+
isWhatsAppNewsletterJid(trimmed) ||
109120
isWhatsAppUserTarget(trimmed) ||
110121
normalizeWhatsAppTarget(trimmed) !== null
111122
);

extensions/whatsapp/src/normalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export {
22
looksLikeWhatsAppTargetId,
33
normalizeWhatsAppMessagingTarget,
44
isWhatsAppGroupJid,
5+
isWhatsAppNewsletterJid,
56
normalizeWhatsAppTarget,
67
} from "./normalize-target.js";

extensions/whatsapp/src/resolve-outbound-target.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ describe("resolveWhatsAppOutboundTarget", () => {
130130
});
131131
});
132132

133+
describe("newsletter JID handling", () => {
134+
it("returns success for valid newsletter JID without applying DM allowFrom", () => {
135+
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce(
136+
"120363123456789@newsletter",
137+
);
138+
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
139+
vi.mocked(normalize.isWhatsAppNewsletterJid).mockReturnValueOnce(true);
140+
141+
expectResolutionOk(
142+
{
143+
to: "120363123456789@newsletter",
144+
allowFrom: [SECONDARY_TARGET],
145+
mode: "implicit",
146+
},
147+
"120363123456789@newsletter",
148+
);
149+
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledOnce();
150+
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith(
151+
"120363123456789@newsletter",
152+
);
153+
});
154+
});
155+
133156
describe("implicit/heartbeat mode with allowList", () => {
134157
it("allows message when wildcard is present", () => {
135158
mockNormalizedDirectMessage(PRIMARY_TARGET, PRIMARY_TARGET);
@@ -154,14 +177,14 @@ describe("resolveWhatsAppOutboundTarget", () => {
154177
allowFrom: [SECONDARY_TARGET],
155178
mode: "implicit",
156179
},
157-
`Target "${SECONDARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
180+
`Target "${PRIMARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
158181
);
159182
});
160183

161184
it("uses the normalized target in the allowFrom error message", () => {
162185
vi.mocked(normalize.normalizeWhatsAppTarget)
163-
.mockReturnValueOnce(SECONDARY_TARGET)
164-
.mockReturnValueOnce(PRIMARY_TARGET);
186+
.mockReturnValueOnce(PRIMARY_TARGET)
187+
.mockReturnValueOnce(SECONDARY_TARGET);
165188
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
166189

167190
expectResolutionErrorMessage(
@@ -189,8 +212,8 @@ describe("resolveWhatsAppOutboundTarget", () => {
189212

190213
it("filters out invalid normalized entries from allowList", () => {
191214
vi.mocked(normalize.normalizeWhatsAppTarget)
192-
.mockReturnValueOnce(null)
193215
.mockReturnValueOnce("+11234567890")
216+
.mockReturnValueOnce(null)
194217
.mockReturnValueOnce("+11234567890");
195218
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
196219

0 commit comments

Comments
 (0)