@openclaw/whatsapp 2026.5.2-beta.1 → 2026.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/whatsapp",
3
- "version": "2026.5.2-beta.1",
3
+ "version": "2026.5.2",
4
4
  "description": "OpenClaw WhatsApp channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "openclaw": "workspace:*"
20
20
  },
21
21
  "peerDependencies": {
22
- "openclaw": ">=2026.5.2-beta.1"
22
+ "openclaw": ">=2026.5.2"
23
23
  },
24
24
  "peerDependenciesMeta": {
25
25
  "openclaw": {
@@ -61,10 +61,10 @@
61
61
  "minHostVersion": ">=2026.4.25"
62
62
  },
63
63
  "compat": {
64
- "pluginApi": ">=2026.5.2-beta.1"
64
+ "pluginApi": ">=2026.5.2"
65
65
  },
66
66
  "build": {
67
- "openclawVersion": "2026.5.2-beta.1"
67
+ "openclawVersion": "2026.5.2"
68
68
  },
69
69
  "release": {
70
70
  "publishToClawHub": true,
package/src/channel.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
29
29
  import {
30
30
  isWhatsAppGroupJid,
31
+ isWhatsAppNewsletterJid,
31
32
  looksLikeWhatsAppTargetId,
32
33
  normalizeWhatsAppMessagingTarget,
33
34
  normalizeWhatsAppTarget,
@@ -56,7 +57,11 @@ function parseWhatsAppExplicitTarget(raw: string) {
56
57
  }
57
58
  return {
58
59
  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),
60
65
  };
61
66
  }
62
67
 
@@ -117,7 +122,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
117
122
  inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
118
123
  targetResolver: {
119
124
  looksLikeId: looksLikeWhatsAppTargetId,
120
- hint: "<E.164|group JID>",
125
+ hint: "<E.164|group JID|newsletter JID>",
121
126
  },
122
127
  },
123
128
  directory: {
@@ -260,6 +260,18 @@ describe("createWebSendApi", () => {
260
260
  expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net");
261
261
  });
262
262
 
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
+
263
275
  it("sends media as document when mediaType is undefined", async () => {
264
276
  const mediaBuffer = Buffer.from("test");
265
277
 
@@ -5,6 +5,7 @@ import type {
5
5
  WAPresence,
6
6
  } from "@whiskeysockets/baileys";
7
7
  import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
8
+ import { isWhatsAppNewsletterJid } from "../normalize.js";
8
9
  import { buildQuotedMessageOptions } from "../quoted-message.js";
9
10
  import { toWhatsappJid } from "../text-runtime.js";
10
11
  import {
@@ -135,6 +136,9 @@ export function createWebSendApi(params: {
135
136
  },
136
137
  sendComposingTo: async (to: string): Promise<void> => {
137
138
  const jid = toWhatsappJid(to);
139
+ if (isWhatsAppNewsletterJid(jid)) {
140
+ return;
141
+ }
138
142
  await params.sock.sendPresenceUpdate("composing", jid);
139
143
  },
140
144
  } as const;
@@ -5,6 +5,7 @@ const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i;
5
5
  const WHATSAPP_LEGACY_USER_JID_RE = /^(\d+)@c\.us$/i;
6
6
  const WHATSAPP_LID_RE = /^(\d+)@lid$/i;
7
7
  const NON_WHATSAPP_PROVIDER_PREFIX_RE = /^[a-z][a-z0-9-]*:/i;
8
+ const WHATSAPP_NEWSLETTER_JID_RE = /^([0-9]+)@newsletter$/i;
8
9
 
9
10
  function stripWhatsAppTargetPrefixes(value: string): string {
10
11
  let candidate = value.trim();
@@ -30,6 +31,11 @@ export function isWhatsAppGroupJid(value: string): boolean {
30
31
  return /^[0-9]+(-[0-9]+)*$/.test(localPart);
31
32
  }
32
33
 
34
+ export function isWhatsAppNewsletterJid(value: string): boolean {
35
+ const candidate = stripWhatsAppTargetPrefixes(value);
36
+ return WHATSAPP_NEWSLETTER_JID_RE.test(candidate);
37
+ }
38
+
33
39
  export function isWhatsAppUserTarget(value: string): boolean {
34
40
  const candidate = stripWhatsAppTargetPrefixes(value);
35
41
  return (
@@ -64,6 +70,10 @@ export function normalizeWhatsAppTarget(value: string): string | null {
64
70
  const localPart = candidate.slice(0, candidate.length - "@g.us".length);
65
71
  return `${localPart}@g.us`;
66
72
  }
73
+ if (isWhatsAppNewsletterJid(candidate)) {
74
+ const match = candidate.match(WHATSAPP_NEWSLETTER_JID_RE);
75
+ return match ? `${match[1]}@newsletter` : null;
76
+ }
67
77
  if (isWhatsAppUserTarget(candidate)) {
68
78
  const phone = extractUserJidPhone(candidate);
69
79
  if (!phone) {
@@ -106,6 +116,7 @@ export function looksLikeWhatsAppTargetId(raw: string): boolean {
106
116
  return (
107
117
  /^whatsapp:/i.test(trimmed) ||
108
118
  isWhatsAppGroupJid(trimmed) ||
119
+ isWhatsAppNewsletterJid(trimmed) ||
109
120
  isWhatsAppUserTarget(trimmed) ||
110
121
  normalizeWhatsAppTarget(trimmed) !== null
111
122
  );
package/src/normalize.ts CHANGED
@@ -2,5 +2,6 @@ export {
2
2
  looksLikeWhatsAppTargetId,
3
3
  normalizeWhatsAppMessagingTarget,
4
4
  isWhatsAppGroupJid,
5
+ isWhatsAppNewsletterJid,
5
6
  normalizeWhatsAppTarget,
6
7
  } from "./normalize-target.js";
@@ -130,6 +130,29 @@ describe("resolveWhatsAppOutboundTarget", () => {
130
130
  });
131
131
  });
132
132
 
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
+
133
156
  describe("implicit/heartbeat mode with allowList", () => {
134
157
  it("allows message when wildcard is present", () => {
135
158
  mockNormalizedDirectMessage(PRIMARY_TARGET, PRIMARY_TARGET);
@@ -154,14 +177,14 @@ describe("resolveWhatsAppOutboundTarget", () => {
154
177
  allowFrom: [SECONDARY_TARGET],
155
178
  mode: "implicit",
156
179
  },
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.`,
158
181
  );
159
182
  });
160
183
 
161
184
  it("uses the normalized target in the allowFrom error message", () => {
162
185
  vi.mocked(normalize.normalizeWhatsAppTarget)
163
- .mockReturnValueOnce(SECONDARY_TARGET)
164
- .mockReturnValueOnce(PRIMARY_TARGET);
186
+ .mockReturnValueOnce(PRIMARY_TARGET)
187
+ .mockReturnValueOnce(SECONDARY_TARGET);
165
188
  vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
166
189
 
167
190
  expectResolutionErrorMessage(
@@ -189,8 +212,8 @@ describe("resolveWhatsAppOutboundTarget", () => {
189
212
 
190
213
  it("filters out invalid normalized entries from allowList", () => {
191
214
  vi.mocked(normalize.normalizeWhatsAppTarget)
192
- .mockReturnValueOnce(null)
193
215
  .mockReturnValueOnce("+11234567890")
216
+ .mockReturnValueOnce(null)
194
217
  .mockReturnValueOnce("+11234567890");
195
218
  vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
196
219
 
@@ -1,5 +1,9 @@
1
1
  import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
2
- import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize-target.js";
2
+ import {
3
+ isWhatsAppGroupJid,
4
+ isWhatsAppNewsletterJid,
5
+ normalizeWhatsAppTarget,
6
+ } from "./normalize-target.js";
3
7
 
4
8
  export type WhatsAppOutboundTargetResolution =
5
9
  | { ok: true; to: string }
@@ -15,6 +19,24 @@ export function resolveWhatsAppOutboundTarget(params: {
15
19
  mode: string | null | undefined;
16
20
  }): WhatsAppOutboundTargetResolution {
17
21
  const trimmed = params.to?.trim() ?? "";
22
+ if (!trimmed) {
23
+ return {
24
+ ok: false,
25
+ error: missingTargetError("WhatsApp", "<E.164|group JID|newsletter JID>"),
26
+ };
27
+ }
28
+
29
+ const normalizedTo = normalizeWhatsAppTarget(trimmed);
30
+ if (!normalizedTo) {
31
+ return {
32
+ ok: false,
33
+ error: missingTargetError("WhatsApp", "<E.164|group JID|newsletter JID>"),
34
+ };
35
+ }
36
+ if (isWhatsAppGroupJid(normalizedTo) || isWhatsAppNewsletterJid(normalizedTo)) {
37
+ return { ok: true, to: normalizedTo };
38
+ }
39
+
18
40
  const allowListRaw = (params.allowFrom ?? [])
19
41
  .map((entry) => String(entry).trim())
20
42
  .filter(Boolean);
@@ -23,32 +45,14 @@ export function resolveWhatsAppOutboundTarget(params: {
23
45
  .filter((entry) => entry !== "*")
24
46
  .map((entry) => normalizeWhatsAppTarget(entry))
25
47
  .filter((entry): entry is string => Boolean(entry));
26
-
27
- if (trimmed) {
28
- const normalizedTo = normalizeWhatsAppTarget(trimmed);
29
- if (!normalizedTo) {
30
- return {
31
- ok: false,
32
- error: missingTargetError("WhatsApp", "<E.164|group JID>"),
33
- };
34
- }
35
- if (isWhatsAppGroupJid(normalizedTo)) {
36
- return { ok: true, to: normalizedTo };
37
- }
38
- if (hasWildcard || allowList.length === 0) {
39
- return { ok: true, to: normalizedTo };
40
- }
41
- if (allowList.includes(normalizedTo)) {
42
- return { ok: true, to: normalizedTo };
43
- }
44
- return {
45
- ok: false,
46
- error: whatsappAllowFromPolicyError(normalizedTo),
47
- };
48
+ if (hasWildcard || allowList.length === 0) {
49
+ return { ok: true, to: normalizedTo };
50
+ }
51
+ if (allowList.includes(normalizedTo)) {
52
+ return { ok: true, to: normalizedTo };
48
53
  }
49
-
50
54
  return {
51
55
  ok: false,
52
- error: missingTargetError("WhatsApp", "<E.164|group JID>"),
56
+ error: whatsappAllowFromPolicyError(normalizedTo),
53
57
  };
54
58
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  isWhatsAppGroupJid,
4
+ isWhatsAppNewsletterJid,
4
5
  looksLikeWhatsAppTargetId,
5
6
  isWhatsAppUserTarget,
6
7
  normalizeWhatsAppMessagingTarget,
@@ -16,6 +17,15 @@ describe("normalizeWhatsAppTarget", () => {
16
17
  );
17
18
  });
18
19
 
20
+ it("preserves newsletter JIDs", () => {
21
+ expect(normalizeWhatsAppTarget("120363401234567890@newsletter")).toBe(
22
+ "120363401234567890@newsletter",
23
+ );
24
+ expect(normalizeWhatsAppTarget("WhatsApp:120363401234567890@NEWSLETTER")).toBe(
25
+ "120363401234567890@newsletter",
26
+ );
27
+ });
28
+
19
29
  it("normalizes direct JIDs to E.164", () => {
20
30
  expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123");
21
31
  });
@@ -40,6 +50,7 @@ describe("normalizeWhatsAppTarget", () => {
40
50
  expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull();
41
51
  expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull();
42
52
  expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull();
53
+ expect(normalizeWhatsAppTarget("abc@newsletter")).toBeNull();
43
54
  });
44
55
 
45
56
  it("rejects non-WhatsApp provider-prefixed phone-like targets", () => {
@@ -68,6 +79,17 @@ describe("isWhatsAppUserTarget", () => {
68
79
  });
69
80
  });
70
81
 
82
+ describe("isWhatsAppNewsletterJid", () => {
83
+ it("detects newsletter JIDs with or without prefixes", () => {
84
+ expect(isWhatsAppNewsletterJid("120363401234567890@newsletter")).toBe(true);
85
+ expect(isWhatsAppNewsletterJid("whatsapp:120363401234567890@newsletter")).toBe(true);
86
+ expect(isWhatsAppNewsletterJid("120363401234567890@NEWSLETTER")).toBe(true);
87
+ expect(isWhatsAppNewsletterJid("abc@newsletter")).toBe(false);
88
+ expect(isWhatsAppNewsletterJid("120363401234567890@g.us")).toBe(false);
89
+ expect(isWhatsAppNewsletterJid("+1555123")).toBe(false);
90
+ });
91
+ });
92
+
71
93
  describe("isWhatsAppGroupJid", () => {
72
94
  it("detects group JIDs with or without prefixes", () => {
73
95
  expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
@@ -91,6 +113,7 @@ describe("looksLikeWhatsAppTargetId", () => {
91
113
  it("detects common WhatsApp target forms", () => {
92
114
  expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
93
115
  expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
116
+ expect(looksLikeWhatsAppTargetId("120363401234567890@newsletter")).toBe(true);
94
117
  expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
95
118
  expect(looksLikeWhatsAppTargetId("")).toBe(false);
96
119
  });
package/src/send.test.ts CHANGED
@@ -144,6 +144,25 @@ describe("web outbound", () => {
144
144
  expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
145
145
  });
146
146
 
147
+ it("sends newsletter messages via the active listener without composing presence", async () => {
148
+ const result = await sendMessageWhatsApp("120363401234567890@newsletter", "hi", {
149
+ verbose: false,
150
+ cfg: WHATSAPP_TEST_CFG,
151
+ });
152
+
153
+ expect(result).toEqual({
154
+ messageId: "msg123",
155
+ toJid: "120363401234567890@newsletter",
156
+ });
157
+ expect(sendComposingTo).not.toHaveBeenCalled();
158
+ expect(sendMessage).toHaveBeenCalledWith(
159
+ "120363401234567890@newsletter",
160
+ "hi",
161
+ undefined,
162
+ undefined,
163
+ );
164
+ });
165
+
147
166
  it("uses configured defaultAccount when outbound accountId is omitted", async () => {
148
167
  hoisted.controllerListeners.clear();
149
168
  hoisted.controllerListeners.set("work", {
package/src/send.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./accounts.js";
17
17
  import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
18
18
  import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
19
+ import { isWhatsAppNewsletterJid } from "./normalize.js";
19
20
  import {
20
21
  normalizeWhatsAppPayloadText,
21
22
  prepareWhatsAppOutboundMedia,
@@ -142,7 +143,9 @@ export async function sendMessageWhatsApp(
142
143
  }
143
144
  outboundLog.info(`Sending message -> ${redactedJid}${primaryMediaUrl ? " (media)" : ""}`);
144
145
  logger.info({ jid: redactedJid, hasMedia: Boolean(primaryMediaUrl) }, "sending message");
145
- await active.sendComposingTo(to);
146
+ if (!isWhatsAppNewsletterJid(jid)) {
147
+ await active.sendComposingTo(to);
148
+ }
146
149
  const hasExplicitAccountId = Boolean(options.accountId?.trim());
147
150
  const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
148
151
  const sendOptions: ActiveWebSendOptions | undefined =
@@ -192,7 +195,9 @@ export async function sendTypingWhatsApp(
192
195
  cfg,
193
196
  accountId: options.accountId,
194
197
  });
195
- await active.sendComposingTo(to);
198
+ if (!isWhatsAppNewsletterJid(toWhatsappJid(to))) {
199
+ await active.sendComposingTo(to);
200
+ }
196
201
  }
197
202
 
198
203
  export async function sendReactionWhatsApp(
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js";
3
+
4
+ describe("resolveWhatsAppOutboundSessionRoute", () => {
5
+ it("routes newsletter JIDs as channel sessions", () => {
6
+ const route = resolveWhatsAppOutboundSessionRoute({
7
+ cfg: {},
8
+ agentId: "main",
9
+ target: "120363401234567890@newsletter",
10
+ });
11
+
12
+ expect(route).toMatchObject({
13
+ sessionKey: "agent:main:whatsapp:channel:120363401234567890@newsletter",
14
+ baseSessionKey: "agent:main:whatsapp:channel:120363401234567890@newsletter",
15
+ peer: {
16
+ kind: "channel",
17
+ id: "120363401234567890@newsletter",
18
+ },
19
+ chatType: "channel",
20
+ from: "120363401234567890@newsletter",
21
+ to: "120363401234567890@newsletter",
22
+ });
23
+ });
24
+
25
+ it("keeps direct user targets on direct session semantics", () => {
26
+ const route = resolveWhatsAppOutboundSessionRoute({
27
+ cfg: { session: { dmScope: "per-channel-peer" } },
28
+ agentId: "main",
29
+ target: "+15551234567",
30
+ });
31
+
32
+ expect(route).toMatchObject({
33
+ sessionKey: "agent:main:whatsapp:direct:+15551234567",
34
+ peer: {
35
+ kind: "direct",
36
+ id: "+15551234567",
37
+ },
38
+ chatType: "direct",
39
+ });
40
+ });
41
+ });
@@ -2,7 +2,11 @@ import {
2
2
  buildChannelOutboundSessionRoute,
3
3
  type ChannelOutboundSessionRouteParams,
4
4
  } from "openclaw/plugin-sdk/core";
5
- import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js";
5
+ import {
6
+ isWhatsAppGroupJid,
7
+ isWhatsAppNewsletterJid,
8
+ normalizeWhatsAppTarget,
9
+ } from "./normalize.js";
6
10
 
7
11
  export function resolveWhatsAppOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
8
12
  const normalized = normalizeWhatsAppTarget(params.target);
@@ -10,16 +14,18 @@ export function resolveWhatsAppOutboundSessionRoute(params: ChannelOutboundSessi
10
14
  return null;
11
15
  }
12
16
  const isGroup = isWhatsAppGroupJid(normalized);
17
+ const isNewsletter = isWhatsAppNewsletterJid(normalized);
18
+ const chatType = isGroup ? "group" : isNewsletter ? "channel" : "direct";
13
19
  return buildChannelOutboundSessionRoute({
14
20
  cfg: params.cfg,
15
21
  agentId: params.agentId,
16
22
  channel: "whatsapp",
17
23
  accountId: params.accountId,
18
24
  peer: {
19
- kind: isGroup ? "group" : "direct",
25
+ kind: chatType,
20
26
  id: normalized,
21
27
  },
22
- chatType: isGroup ? "group" : "direct",
28
+ chatType,
23
29
  from: normalized,
24
30
  to: normalized,
25
31
  });
package/src/shared.ts CHANGED
@@ -208,7 +208,7 @@ export function createWhatsAppPluginBase(params: {
208
208
  },
209
209
  setupWizard: params.setupWizard,
210
210
  capabilities: {
211
- chatTypes: ["direct", "group"],
211
+ chatTypes: ["direct", "group", "channel"],
212
212
  polls: true,
213
213
  reactions: true,
214
214
  media: true,