feat(whatsapp): support newsletter targets in message tool

This commit is contained in:
vincentkoc
2026-04-28 07:42:35 +00:00
committed by clawsweeper
parent 08ce17c33d
commit 40d2149630
17 changed files with 203 additions and 40 deletions

View File

@@ -8,6 +8,11 @@ Docs: https://docs.openclaw.ai
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
- 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.
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
- 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 target idea from #13424 while addressing routing/session review concerns; refs #62697. Thanks @vincentkoc and @agentz-manfred.
### Fixes

View File

@@ -158,6 +158,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
- 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.
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
- 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.
- 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.
- When `messages.removeAckAfterReply` is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered.
@@ -214,6 +215,8 @@ content and identifiers.
`allowFrom` accepts E.164-style numbers (normalized internally).
`allowFrom` is a DM sender access-control list. It does not gate explicit outbound sends to WhatsApp group JIDs or `@newsletter` channel JIDs.
Multi-account override: `channels.whatsapp.accounts.<id>.dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account.
Runtime behavior details:

View File

@@ -32,7 +32,7 @@ openclaw message send --channel slack --target user:U012ABCDEF --message "hello"
## ID formats (by channel)
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group), `120363123456789@newsletter` (Channel/Newsletter outbound target)
- Telegram: `@username` or numeric chat id; groups are numeric ids
- Slack: `user:U…` and `channel:C…`
- Discord: `user:<id>` and `channel:<id>`

View File

@@ -26,7 +26,7 @@ Channel selection:
Target formats (`--target`):
- WhatsApp: E.164 or group JID
- WhatsApp: E.164, group JID, or WhatsApp Channel/Newsletter JID (`...@newsletter`)
- Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
@@ -76,7 +76,7 @@ Name lookup:
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- Telegram + Discord: `--silent`
- WhatsApp only: `--gif-playback`
- WhatsApp only: `--gif-playback`; WhatsApp Channels/Newsletters are addressed with their native `@newsletter` JID.
- `poll`
- Channels: WhatsApp/Telegram/Discord/Matrix/Microsoft Teams

View File

@@ -28,6 +28,7 @@ import {
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import {
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
@@ -56,7 +57,11 @@ function parseWhatsAppExplicitTarget(raw: string) {
}
return {
to: normalized,
chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const),
chatType: isWhatsAppGroupJid(normalized)
? ("group" as const)
: isWhatsAppNewsletterJid(normalized)
? ("channel" as const)
: ("direct" as const),
};
}
@@ -117,7 +122,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
targetResolver: {
looksLikeId: looksLikeWhatsAppTargetId,
hint: "<E.164|group JID>",
hint: "<E.164|group JID|newsletter JID>",
},
},
directory: {

View File

@@ -260,6 +260,18 @@ describe("createWebSendApi", () => {
expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net");
});
it("does not send composing presence to newsletter JIDs", async () => {
await api.sendComposingTo("120363401234567890@newsletter");
expect(sendPresenceUpdate).not.toHaveBeenCalled();
});
it("preserves newsletter JIDs for outbound sends", async () => {
await api.sendMessage("120363401234567890@newsletter", "hello");
expect(sendMessage).toHaveBeenCalledWith("120363401234567890@newsletter", {
text: "hello",
});
});
it("sends media as document when mediaType is undefined", async () => {
const mediaBuffer = Buffer.from("test");

View File

@@ -5,6 +5,7 @@ import type {
WAPresence,
} from "@whiskeysockets/baileys";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import { isWhatsAppNewsletterJid } from "../normalize.js";
import { buildQuotedMessageOptions } from "../quoted-message.js";
import { toWhatsappJid } from "../text-runtime.js";
import {
@@ -135,6 +136,9 @@ export function createWebSendApi(params: {
},
sendComposingTo: async (to: string): Promise<void> => {
const jid = toWhatsappJid(to);
if (isWhatsAppNewsletterJid(jid)) {
return;
}
await params.sock.sendPresenceUpdate("composing", jid);
},
} as const;

View File

@@ -5,6 +5,7 @@ const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i;
const WHATSAPP_LEGACY_USER_JID_RE = /^(\d+)@c\.us$/i;
const WHATSAPP_LID_RE = /^(\d+)@lid$/i;
const NON_WHATSAPP_PROVIDER_PREFIX_RE = /^[a-z][a-z0-9-]*:/i;
const WHATSAPP_NEWSLETTER_JID_RE = /^([0-9]+)@newsletter$/i;
function stripWhatsAppTargetPrefixes(value: string): string {
let candidate = value.trim();
@@ -30,6 +31,11 @@ export function isWhatsAppGroupJid(value: string): boolean {
return /^[0-9]+(-[0-9]+)*$/.test(localPart);
}
export function isWhatsAppNewsletterJid(value: string): boolean {
const candidate = stripWhatsAppTargetPrefixes(value);
return WHATSAPP_NEWSLETTER_JID_RE.test(candidate);
}
export function isWhatsAppUserTarget(value: string): boolean {
const candidate = stripWhatsAppTargetPrefixes(value);
return (
@@ -64,6 +70,10 @@ export function normalizeWhatsAppTarget(value: string): string | null {
const localPart = candidate.slice(0, candidate.length - "@g.us".length);
return `${localPart}@g.us`;
}
if (isWhatsAppNewsletterJid(candidate)) {
const match = candidate.match(WHATSAPP_NEWSLETTER_JID_RE);
return match ? `${match[1]}@newsletter` : null;
}
if (isWhatsAppUserTarget(candidate)) {
const phone = extractUserJidPhone(candidate);
if (!phone) {
@@ -106,6 +116,7 @@ export function looksLikeWhatsAppTargetId(raw: string): boolean {
return (
/^whatsapp:/i.test(trimmed) ||
isWhatsAppGroupJid(trimmed) ||
isWhatsAppNewsletterJid(trimmed) ||
isWhatsAppUserTarget(trimmed) ||
normalizeWhatsAppTarget(trimmed) !== null
);

View File

@@ -2,5 +2,7 @@ export {
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
isWhatsAppUserTarget,
normalizeWhatsAppTarget,
} from "./normalize-target.js";

View File

@@ -130,6 +130,29 @@ describe("resolveWhatsAppOutboundTarget", () => {
});
});
describe("newsletter JID handling", () => {
it("returns success for valid newsletter JID without applying DM allowFrom", () => {
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce(
"120363123456789@newsletter",
);
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
vi.mocked(normalize.isWhatsAppNewsletterJid).mockReturnValueOnce(true);
expectResolutionOk(
{
to: "120363123456789@newsletter",
allowFrom: [SECONDARY_TARGET],
mode: "implicit",
},
"120363123456789@newsletter",
);
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledOnce();
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith(
"120363123456789@newsletter",
);
});
});
describe("implicit/heartbeat mode with allowList", () => {
it("allows message when wildcard is present", () => {
mockNormalizedDirectMessage(PRIMARY_TARGET, PRIMARY_TARGET);
@@ -154,14 +177,14 @@ describe("resolveWhatsAppOutboundTarget", () => {
allowFrom: [SECONDARY_TARGET],
mode: "implicit",
},
`Target "${SECONDARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
`Target "${PRIMARY_TARGET}" is not listed in the configured WhatsApp allowFrom policy.`,
);
});
it("uses the normalized target in the allowFrom error message", () => {
vi.mocked(normalize.normalizeWhatsAppTarget)
.mockReturnValueOnce(SECONDARY_TARGET)
.mockReturnValueOnce(PRIMARY_TARGET);
.mockReturnValueOnce(PRIMARY_TARGET)
.mockReturnValueOnce(SECONDARY_TARGET);
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
expectResolutionErrorMessage(
@@ -189,8 +212,8 @@ describe("resolveWhatsAppOutboundTarget", () => {
it("filters out invalid normalized entries from allowList", () => {
vi.mocked(normalize.normalizeWhatsAppTarget)
.mockReturnValueOnce(null)
.mockReturnValueOnce("+11234567890")
.mockReturnValueOnce(null)
.mockReturnValueOnce("+11234567890");
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);

View File

@@ -1,5 +1,9 @@
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize-target.js";
import {
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
normalizeWhatsAppTarget,
} from "./normalize-target.js";
export type WhatsAppOutboundTargetResolution =
| { ok: true; to: string }
@@ -15,6 +19,24 @@ export function resolveWhatsAppOutboundTarget(params: {
mode: string | null | undefined;
}): WhatsAppOutboundTargetResolution {
const trimmed = params.to?.trim() ?? "";
if (!trimmed) {
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID|newsletter JID>"),
};
}
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID|newsletter JID>"),
};
}
if (isWhatsAppGroupJid(normalizedTo) || isWhatsAppNewsletterJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
const allowListRaw = (params.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean);
@@ -23,32 +45,14 @@ export function resolveWhatsAppOutboundTarget(params: {
.filter((entry) => entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
};
}
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: whatsappAllowFromPolicyError(normalizedTo),
};
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
error: whatsappAllowFromPolicyError(normalizedTo),
};
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
looksLikeWhatsAppTargetId,
isWhatsAppUserTarget,
normalizeWhatsAppMessagingTarget,
@@ -16,6 +17,15 @@ describe("normalizeWhatsAppTarget", () => {
);
});
it("preserves newsletter JIDs", () => {
expect(normalizeWhatsAppTarget("120363401234567890@newsletter")).toBe(
"120363401234567890@newsletter",
);
expect(normalizeWhatsAppTarget("WhatsApp:120363401234567890@NEWSLETTER")).toBe(
"120363401234567890@newsletter",
);
});
it("normalizes direct JIDs to E.164", () => {
expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123");
});
@@ -40,6 +50,7 @@ describe("normalizeWhatsAppTarget", () => {
expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull();
expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull();
expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull();
expect(normalizeWhatsAppTarget("abc@newsletter")).toBeNull();
});
it("rejects non-WhatsApp provider-prefixed phone-like targets", () => {
@@ -68,6 +79,17 @@ describe("isWhatsAppUserTarget", () => {
});
});
describe("isWhatsAppNewsletterJid", () => {
it("detects newsletter JIDs with or without prefixes", () => {
expect(isWhatsAppNewsletterJid("120363401234567890@newsletter")).toBe(true);
expect(isWhatsAppNewsletterJid("whatsapp:120363401234567890@newsletter")).toBe(true);
expect(isWhatsAppNewsletterJid("120363401234567890@NEWSLETTER")).toBe(true);
expect(isWhatsAppNewsletterJid("abc@newsletter")).toBe(false);
expect(isWhatsAppNewsletterJid("120363401234567890@g.us")).toBe(false);
expect(isWhatsAppNewsletterJid("+1555123")).toBe(false);
});
});
describe("isWhatsAppGroupJid", () => {
it("detects group JIDs with or without prefixes", () => {
expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
@@ -91,6 +113,7 @@ describe("looksLikeWhatsAppTargetId", () => {
it("detects common WhatsApp target forms", () => {
expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
expect(looksLikeWhatsAppTargetId("120363401234567890@newsletter")).toBe(true);
expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
expect(looksLikeWhatsAppTargetId("")).toBe(false);
});

View File

@@ -144,6 +144,25 @@ describe("web outbound", () => {
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
});
it("sends newsletter messages via the active listener without composing presence", async () => {
const result = await sendMessageWhatsApp("120363401234567890@newsletter", "hi", {
verbose: false,
cfg: WHATSAPP_TEST_CFG,
});
expect(result).toEqual({
messageId: "msg123",
toJid: "120363401234567890@newsletter",
});
expect(sendComposingTo).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
"120363401234567890@newsletter",
"hi",
undefined,
undefined,
);
});
it("uses configured defaultAccount when outbound accountId is omitted", async () => {
hoisted.controllerListeners.clear();
hoisted.controllerListeners.set("work", {

View File

@@ -16,6 +16,7 @@ import {
} from "./accounts.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
import { isWhatsAppNewsletterJid } from "./normalize.js";
import {
normalizeWhatsAppPayloadText,
prepareWhatsAppOutboundMedia,
@@ -142,7 +143,9 @@ export async function sendMessageWhatsApp(
}
outboundLog.info(`Sending message -> ${redactedJid}${primaryMediaUrl ? " (media)" : ""}`);
logger.info({ jid: redactedJid, hasMedia: Boolean(primaryMediaUrl) }, "sending message");
await active.sendComposingTo(to);
if (!isWhatsAppNewsletterJid(jid)) {
await active.sendComposingTo(to);
}
const hasExplicitAccountId = Boolean(options.accountId?.trim());
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
const sendOptions: ActiveWebSendOptions | undefined =
@@ -192,7 +195,9 @@ export async function sendTypingWhatsApp(
cfg,
accountId: options.accountId,
});
await active.sendComposingTo(to);
if (!isWhatsAppNewsletterJid(toWhatsappJid(to))) {
await active.sendComposingTo(to);
}
}
export async function sendReactionWhatsApp(

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js";
describe("resolveWhatsAppOutboundSessionRoute", () => {
it("routes newsletter JIDs as channel sessions", () => {
const route = resolveWhatsAppOutboundSessionRoute({
cfg: {},
agentId: "main",
target: "120363401234567890@newsletter",
});
expect(route).toMatchObject({
sessionKey: "agent:main:whatsapp:channel:120363401234567890@newsletter",
baseSessionKey: "agent:main:whatsapp:channel:120363401234567890@newsletter",
peer: {
kind: "channel",
id: "120363401234567890@newsletter",
},
chatType: "channel",
from: "120363401234567890@newsletter",
to: "120363401234567890@newsletter",
});
});
it("keeps direct user targets on direct session semantics", () => {
const route = resolveWhatsAppOutboundSessionRoute({
cfg: { session: { dmScope: "per-channel-peer" } },
agentId: "main",
target: "+15551234567",
});
expect(route).toMatchObject({
sessionKey: "agent:main:whatsapp:direct:+15551234567",
peer: {
kind: "direct",
id: "+15551234567",
},
chatType: "direct",
});
});
});

View File

@@ -2,7 +2,11 @@ import {
buildChannelOutboundSessionRoute,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js";
import {
isWhatsAppGroupJid,
isWhatsAppNewsletterJid,
normalizeWhatsAppTarget,
} from "./normalize.js";
export function resolveWhatsAppOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const normalized = normalizeWhatsAppTarget(params.target);
@@ -10,16 +14,18 @@ export function resolveWhatsAppOutboundSessionRoute(params: ChannelOutboundSessi
return null;
}
const isGroup = isWhatsAppGroupJid(normalized);
const isNewsletter = isWhatsAppNewsletterJid(normalized);
const chatType = isGroup ? "group" : isNewsletter ? "channel" : "direct";
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "whatsapp",
accountId: params.accountId,
peer: {
kind: isGroup ? "group" : "direct",
kind: chatType,
id: normalized,
},
chatType: isGroup ? "group" : "direct",
chatType,
from: normalized,
to: normalized,
});

View File

@@ -208,7 +208,7 @@ export function createWhatsAppPluginBase(params: {
},
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
chatTypes: ["direct", "group", "channel"],
polls: true,
reactions: true,
media: true,