Skip to content

Commit 1650571

Browse files
authored
refactor: move WhatsApp channel implementation to extensions/ (#45725)
* refactor: move WhatsApp channel from src/web/ to extensions/whatsapp/ Move all WhatsApp implementation code (77 source/test files + 9 channel plugin files) from src/web/ and src/channels/plugins/*/whatsapp* to extensions/whatsapp/src/. - Leave thin re-export shims at all original locations so cross-cutting imports continue to resolve - Update plugin-sdk/whatsapp.ts to only re-export generic framework utilities; channel-specific functions imported locally by the extension - Update vi.mock paths in 15 cross-cutting test files - Rename outbound.ts -> send.ts to match extension naming conventions and avoid false positive in cfg-threading guard test - Widen tsconfig.plugin-sdk.dts.json rootDir to support shim->extension cross-directory references Part of the core-channels-to-extensions migration (PR 6/10). * style: format WhatsApp extension files * fix: correct stale import paths in WhatsApp extension tests Fix vi.importActual, test mock, and hardcoded source paths that weren't updated during the file move: - media.test.ts: vi.importActual path - onboarding.test.ts: vi.importActual path - test-helpers.ts: test/mocks/baileys.js path - monitor-inbox.test-harness.ts: incomplete media/store mock - login.test.ts: hardcoded source file path - message-action-runner.media.test.ts: vi.mock/importActual path
1 parent 0ce23dc commit 1650571

File tree

155 files changed

+6959
-6825
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

155 files changed

+6959
-6825
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
4+
import type { OpenClawConfig } from "../../../src/config/config.js";
5+
import { resolveOAuthDir } from "../../../src/config/paths.js";
6+
import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../../../src/config/types.js";
7+
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
8+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
9+
import { resolveUserPath } from "../../../src/utils.js";
10+
import { hasWebCredsSync } from "./auth-store.js";
11+
12+
export type ResolvedWhatsAppAccount = {
13+
accountId: string;
14+
name?: string;
15+
enabled: boolean;
16+
sendReadReceipts: boolean;
17+
messagePrefix?: string;
18+
authDir: string;
19+
isLegacyAuthDir: boolean;
20+
selfChatMode?: boolean;
21+
allowFrom?: string[];
22+
groupAllowFrom?: string[];
23+
groupPolicy?: GroupPolicy;
24+
dmPolicy?: DmPolicy;
25+
textChunkLimit?: number;
26+
chunkMode?: "length" | "newline";
27+
mediaMaxMb?: number;
28+
blockStreaming?: boolean;
29+
ackReaction?: WhatsAppAccountConfig["ackReaction"];
30+
groups?: WhatsAppAccountConfig["groups"];
31+
debounceMs?: number;
32+
};
33+
34+
export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50;
35+
36+
const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } =
37+
createAccountListHelpers("whatsapp");
38+
export const listWhatsAppAccountIds = listAccountIds;
39+
export const resolveDefaultWhatsAppAccountId = resolveDefaultAccountId;
40+
41+
export function listWhatsAppAuthDirs(cfg: OpenClawConfig): string[] {
42+
const oauthDir = resolveOAuthDir();
43+
const whatsappDir = path.join(oauthDir, "whatsapp");
44+
const authDirs = new Set<string>([oauthDir, path.join(whatsappDir, DEFAULT_ACCOUNT_ID)]);
45+
46+
const accountIds = listConfiguredAccountIds(cfg);
47+
for (const accountId of accountIds) {
48+
authDirs.add(resolveWhatsAppAuthDir({ cfg, accountId }).authDir);
49+
}
50+
51+
try {
52+
const entries = fs.readdirSync(whatsappDir, { withFileTypes: true });
53+
for (const entry of entries) {
54+
if (!entry.isDirectory()) {
55+
continue;
56+
}
57+
authDirs.add(path.join(whatsappDir, entry.name));
58+
}
59+
} catch {
60+
// ignore missing dirs
61+
}
62+
63+
return Array.from(authDirs);
64+
}
65+
66+
export function hasAnyWhatsAppAuth(cfg: OpenClawConfig): boolean {
67+
return listWhatsAppAuthDirs(cfg).some((authDir) => hasWebCredsSync(authDir));
68+
}
69+
70+
function resolveAccountConfig(
71+
cfg: OpenClawConfig,
72+
accountId: string,
73+
): WhatsAppAccountConfig | undefined {
74+
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
75+
}
76+
77+
function resolveDefaultAuthDir(accountId: string): string {
78+
return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId));
79+
}
80+
81+
function resolveLegacyAuthDir(): string {
82+
// Legacy Baileys creds lived in the same directory as OAuth tokens.
83+
return resolveOAuthDir();
84+
}
85+
86+
function legacyAuthExists(authDir: string): boolean {
87+
try {
88+
return fs.existsSync(path.join(authDir, "creds.json"));
89+
} catch {
90+
return false;
91+
}
92+
}
93+
94+
export function resolveWhatsAppAuthDir(params: { cfg: OpenClawConfig; accountId: string }): {
95+
authDir: string;
96+
isLegacy: boolean;
97+
} {
98+
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
99+
const account = resolveAccountConfig(params.cfg, accountId);
100+
const configured = account?.authDir?.trim();
101+
if (configured) {
102+
return { authDir: resolveUserPath(configured), isLegacy: false };
103+
}
104+
105+
const defaultDir = resolveDefaultAuthDir(accountId);
106+
if (accountId === DEFAULT_ACCOUNT_ID) {
107+
const legacyDir = resolveLegacyAuthDir();
108+
if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) {
109+
return { authDir: legacyDir, isLegacy: true };
110+
}
111+
}
112+
113+
return { authDir: defaultDir, isLegacy: false };
114+
}
115+
116+
export function resolveWhatsAppAccount(params: {
117+
cfg: OpenClawConfig;
118+
accountId?: string | null;
119+
}): ResolvedWhatsAppAccount {
120+
const rootCfg = params.cfg.channels?.whatsapp;
121+
const accountId = params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg);
122+
const accountCfg = resolveAccountConfig(params.cfg, accountId);
123+
const enabled = accountCfg?.enabled !== false;
124+
const { authDir, isLegacy } = resolveWhatsAppAuthDir({
125+
cfg: params.cfg,
126+
accountId,
127+
});
128+
return {
129+
accountId,
130+
name: accountCfg?.name?.trim() || undefined,
131+
enabled,
132+
sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true,
133+
messagePrefix:
134+
accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix,
135+
authDir,
136+
isLegacyAuthDir: isLegacy,
137+
selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode,
138+
dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy,
139+
allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom,
140+
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
141+
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
142+
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
143+
chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode,
144+
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
145+
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
146+
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
147+
groups: accountCfg?.groups ?? rootCfg?.groups,
148+
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
149+
};
150+
}
151+
152+
export function resolveWhatsAppMediaMaxBytes(
153+
account: Pick<ResolvedWhatsAppAccount, "mediaMaxMb">,
154+
): number {
155+
const mediaMaxMb =
156+
typeof account.mediaMaxMb === "number" && account.mediaMaxMb > 0
157+
? account.mediaMaxMb
158+
: DEFAULT_WHATSAPP_MEDIA_MAX_MB;
159+
return mediaMaxMb * 1024 * 1024;
160+
}
161+
162+
export function listEnabledWhatsAppAccounts(cfg: OpenClawConfig): ResolvedWhatsAppAccount[] {
163+
return listWhatsAppAccountIds(cfg)
164+
.map((accountId) => resolveWhatsAppAccount({ cfg, accountId }))
165+
.filter((account) => account.enabled);
166+
}

src/web/accounts.whatsapp-auth.test.ts renamed to extensions/whatsapp/src/accounts.whatsapp-auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5-
import { captureEnv } from "../test-utils/env.js";
5+
import { captureEnv } from "../../../src/test-utils/env.js";
66
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js";
77

88
describe("hasAnyWhatsAppAuth", () => {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { formatCliCommand } from "../../../src/cli/command-format.js";
2+
import type { PollInput } from "../../../src/polls.js";
3+
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
4+
5+
export type ActiveWebSendOptions = {
6+
gifPlayback?: boolean;
7+
accountId?: string;
8+
fileName?: string;
9+
};
10+
11+
export type ActiveWebListener = {
12+
sendMessage: (
13+
to: string,
14+
text: string,
15+
mediaBuffer?: Buffer,
16+
mediaType?: string,
17+
options?: ActiveWebSendOptions,
18+
) => Promise<{ messageId: string }>;
19+
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
20+
sendReaction: (
21+
chatJid: string,
22+
messageId: string,
23+
emoji: string,
24+
fromMe: boolean,
25+
participant?: string,
26+
) => Promise<void>;
27+
sendComposingTo: (to: string) => Promise<void>;
28+
close?: () => Promise<void>;
29+
};
30+
31+
let _currentListener: ActiveWebListener | null = null;
32+
33+
const listeners = new Map<string, ActiveWebListener>();
34+
35+
export function resolveWebAccountId(accountId?: string | null): string {
36+
return (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
37+
}
38+
39+
export function requireActiveWebListener(accountId?: string | null): {
40+
accountId: string;
41+
listener: ActiveWebListener;
42+
} {
43+
const id = resolveWebAccountId(accountId);
44+
const listener = listeners.get(id) ?? null;
45+
if (!listener) {
46+
throw new Error(
47+
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`openclaw channels login --channel whatsapp --account ${id}`)}.`,
48+
);
49+
}
50+
return { accountId: id, listener };
51+
}
52+
53+
export function setActiveWebListener(listener: ActiveWebListener | null): void;
54+
export function setActiveWebListener(
55+
accountId: string | null | undefined,
56+
listener: ActiveWebListener | null,
57+
): void;
58+
export function setActiveWebListener(
59+
accountIdOrListener: string | ActiveWebListener | null | undefined,
60+
maybeListener?: ActiveWebListener | null,
61+
): void {
62+
const { accountId, listener } =
63+
typeof accountIdOrListener === "string"
64+
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
65+
: {
66+
accountId: DEFAULT_ACCOUNT_ID,
67+
listener: accountIdOrListener ?? null,
68+
};
69+
70+
const id = resolveWebAccountId(accountId);
71+
if (!listener) {
72+
listeners.delete(id);
73+
} else {
74+
listeners.set(id, listener);
75+
}
76+
if (id === DEFAULT_ACCOUNT_ID) {
77+
_currentListener = listener;
78+
}
79+
}
80+
81+
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
82+
const id = resolveWebAccountId(accountId);
83+
return listeners.get(id) ?? null;
84+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Type } from "@sinclair/typebox";
2+
import type { ChannelAgentTool } from "../../../src/channels/plugins/types.js";
3+
4+
export function createWhatsAppLoginTool(): ChannelAgentTool {
5+
return {
6+
label: "WhatsApp Login",
7+
name: "whatsapp_login",
8+
ownerOnly: true,
9+
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
10+
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
11+
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
12+
parameters: Type.Object({
13+
action: Type.Unsafe<"start" | "wait">({
14+
type: "string",
15+
enum: ["start", "wait"],
16+
}),
17+
timeoutMs: Type.Optional(Type.Number()),
18+
force: Type.Optional(Type.Boolean()),
19+
}),
20+
execute: async (_toolCallId, args) => {
21+
const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js");
22+
const action = (args as { action?: string })?.action ?? "start";
23+
if (action === "wait") {
24+
const result = await waitForWebLogin({
25+
timeoutMs:
26+
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
27+
? (args as { timeoutMs?: number }).timeoutMs
28+
: undefined,
29+
});
30+
return {
31+
content: [{ type: "text", text: result.message }],
32+
details: { connected: result.connected },
33+
};
34+
}
35+
36+
const result = await startWebLoginWithQr({
37+
timeoutMs:
38+
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
39+
? (args as { timeoutMs?: number }).timeoutMs
40+
: undefined,
41+
force:
42+
typeof (args as { force?: unknown }).force === "boolean"
43+
? (args as { force?: boolean }).force
44+
: false,
45+
});
46+
47+
if (!result.qrDataUrl) {
48+
return {
49+
content: [
50+
{
51+
type: "text",
52+
text: result.message,
53+
},
54+
],
55+
details: { qr: false },
56+
};
57+
}
58+
59+
const text = [
60+
result.message,
61+
"",
62+
"Open WhatsApp → Linked Devices and scan:",
63+
"",
64+
`![whatsapp-qr](${result.qrDataUrl})`,
65+
].join("\n");
66+
return {
67+
content: [{ type: "text", text }],
68+
details: { qr: true },
69+
};
70+
},
71+
};
72+
}

0 commit comments

Comments
 (0)