Skip to content

Commit 4eef5ed

Browse files
committed
feat(whatsapp): add wacli tools and bump version
1 parent 2187ae0 commit 4eef5ed

7 files changed

Lines changed: 942 additions & 63 deletions

File tree

whatsapp/README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# WhatsApp Plugin
22

3-
Skill-first OpenClaw plugin for sending third-party WhatsApp messages and searching synced history through the local `wacli` CLI.
3+
OpenClaw plugin for working with WhatsApp through the local `wacli` CLI.
44

5-
It is meant for operational messaging, history search, and sync tasks. It is not for normal back-and-forth chats with the user.
5+
It exposes real tools for operational messaging, history search, diagnostics, and backfill. It is not for normal back-and-forth chats with the user.
66

77
## Requirements
88

@@ -19,6 +19,38 @@ Keep sync running when you want continuous indexing:
1919
wacli sync --follow
2020
```
2121

22+
## Config
23+
24+
```json5
25+
{
26+
plugins: {
27+
entries: {
28+
whatsapp: {
29+
enabled: true,
30+
config: {
31+
wacliPath: "/opt/homebrew/bin/wacli",
32+
storePath: "~/.wacli",
33+
requireExplicitSendConfirmation: true,
34+
defaultChatListLimit: 20,
35+
defaultMessageSearchLimit: 20,
36+
},
37+
},
38+
},
39+
},
40+
}
41+
```
42+
43+
`wacliPath` is optional if `wacli` is already on `PATH`.
44+
45+
## Tools
46+
47+
- `whatsapp_chats_list`
48+
- `whatsapp_messages_search`
49+
- `whatsapp_history_backfill`
50+
- `whatsapp_doctor`
51+
- `whatsapp_send_text`
52+
- `whatsapp_send_file`
53+
2254
## Usage
2355

2456
After enabling `whatsapp`, ask for things like:
@@ -28,4 +60,4 @@ After enabling `whatsapp`, ask for things like:
2860
- Send a WhatsApp message to +14155551212 saying that the files are ready.
2961
- Send `/tmp/agenda.pdf` to the team group with caption "Agenda".
3062

31-
The skill works best when the request includes an explicit recipient, exact message text, and a verified chat or phone number.
63+
The send tools require `confirm=true` by default so the agent cannot send a real message without an explicit confirmation step.

whatsapp/dist/index.js

Lines changed: 284 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,289 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
import { Type } from "@sinclair/typebox";
14
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
5+
const execFileAsync = promisify(execFile);
6+
const DEFAULT_CHAT_LIST_LIMIT = 20;
7+
const DEFAULT_MESSAGE_SEARCH_LIMIT = 20;
8+
function toolTextResult(text, details) {
9+
return {
10+
content: [{ type: "text", text }],
11+
details,
12+
};
13+
}
14+
function pickString(record, keys) {
15+
for (const key of keys) {
16+
const value = record[key];
17+
if (typeof value === "string" && value.trim())
18+
return value.trim();
19+
}
20+
return undefined;
21+
}
22+
function pickNumber(record, keys) {
23+
for (const key of keys) {
24+
const value = record[key];
25+
if (typeof value === "number" && Number.isFinite(value))
26+
return value;
27+
if (typeof value === "string" && value.trim()) {
28+
const parsed = Number(value);
29+
if (Number.isFinite(parsed))
30+
return parsed;
31+
}
32+
}
33+
return undefined;
34+
}
35+
function ensureArray(value) {
36+
return Array.isArray(value) ? value : [];
37+
}
38+
function normalizeConfig(input) {
39+
const config = {};
40+
if (typeof input.wacliPath === "string" && input.wacliPath.trim()) {
41+
config.wacliPath = input.wacliPath.trim();
42+
}
43+
if (typeof input.storePath === "string" && input.storePath.trim()) {
44+
config.storePath = input.storePath.trim();
45+
}
46+
if (typeof input.requireExplicitSendConfirmation === "boolean") {
47+
config.requireExplicitSendConfirmation = input.requireExplicitSendConfirmation;
48+
}
49+
if (typeof input.defaultChatListLimit === "number") {
50+
config.defaultChatListLimit = input.defaultChatListLimit;
51+
}
52+
if (typeof input.defaultMessageSearchLimit === "number") {
53+
config.defaultMessageSearchLimit = input.defaultMessageSearchLimit;
54+
}
55+
return config;
56+
}
57+
function baseArgs(config, json) {
58+
const args = [];
59+
if (config.storePath) {
60+
args.push("--store", config.storePath);
61+
}
62+
if (json) {
63+
args.push("--json");
64+
}
65+
return args;
66+
}
67+
async function runWacli(config, args, options = {}) {
68+
const command = config.wacliPath ?? "wacli";
69+
const commandArgs = [...baseArgs(config, Boolean(options.json)), ...args];
70+
const { stdout, stderr } = await execFileAsync(command, commandArgs, {
71+
encoding: "utf8",
72+
maxBuffer: 8 * 1024 * 1024,
73+
});
74+
const text = stdout.trim();
75+
const parsed = options.json && text ? JSON.parse(text) : undefined;
76+
return {
77+
command,
78+
commandArgs,
79+
stdout: text,
80+
stderr: stderr.trim(),
81+
parsed,
82+
};
83+
}
84+
function formatChatList(chats) {
85+
const entries = ensureArray(chats).filter((value) => !!value && typeof value === "object");
86+
if (!entries.length)
87+
return "No WhatsApp chats found.";
88+
return [
89+
"WhatsApp chats",
90+
...entries.map((chat, index) => {
91+
const name = pickString(chat, ["Name", "name", "DisplayName", "displayName", "PushName", "pushName"]) ??
92+
"Unnamed chat";
93+
const jid = pickString(chat, ["JID", "jid", "ChatJID", "chatJid"]) ?? "unknown";
94+
const unread = pickNumber(chat, ["UnreadCount", "unreadCount"]);
95+
return `${index + 1}. ${name} (${jid})${typeof unread === "number" ? ` unread:${unread}` : ""}`;
96+
}),
97+
].join("\n");
98+
}
99+
function formatMessageList(messages) {
100+
const entries = ensureArray(messages).filter((value) => !!value && typeof value === "object");
101+
if (!entries.length)
102+
return "No WhatsApp messages matched the search.";
103+
return [
104+
"Matching WhatsApp messages",
105+
...entries.map((message, index) => {
106+
const chat = pickString(message, ["ChatJID", "chatJid", "JID", "jid"]) ?? "unknown-chat";
107+
const sender = pickString(message, ["Sender", "sender", "SenderName", "senderName", "Author", "author"]) ??
108+
"unknown";
109+
const body = pickString(message, ["Text", "text", "Body", "body", "Message", "message", "DisplayText"]) ??
110+
"(no text)";
111+
const timestamp = pickString(message, ["Timestamp", "timestamp", "CreatedAt", "createdAt", "Time", "time"]) ?? "";
112+
return `${index + 1}. [${chat}] ${sender}${timestamp ? ` @ ${timestamp}` : ""}: ${body}`;
113+
}),
114+
].join("\n");
115+
}
116+
function ensureSendConfirmed(config, confirm) {
117+
if (config.requireExplicitSendConfirmation === false)
118+
return;
119+
if (confirm)
120+
return;
121+
throw new Error("This tool sends a real WhatsApp message. Retry with confirm=true after verifying the target.");
122+
}
2123
export default definePluginEntry({
3124
id: "whatsapp",
4125
name: "WhatsApp",
5-
description: "Guide WhatsApp automation inside OpenClaw using the local wacli CLI",
6-
register() { },
126+
description: "Search chats, backfill history, run diagnostics, and send WhatsApp messages through wacli",
127+
register(api) {
128+
const config = normalizeConfig((api.pluginConfig ?? {}));
129+
api.registerTool({
130+
name: "whatsapp_chats_list",
131+
label: "List WhatsApp chats",
132+
description: "List WhatsApp chats from the local wacli store, optionally filtered by name or number",
133+
parameters: Type.Object({
134+
query: Type.Optional(Type.String({ minLength: 1 })),
135+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
136+
}),
137+
async execute(_id, params) {
138+
const result = await runWacli(config, [
139+
"chats",
140+
"list",
141+
"--limit",
142+
String(params.limit ?? config.defaultChatListLimit ?? DEFAULT_CHAT_LIST_LIMIT),
143+
...(params.query ? ["--query", params.query] : []),
144+
], { json: true });
145+
const chats = ensureArray(result.parsed);
146+
return toolTextResult(formatChatList(chats), {
147+
status: "ok",
148+
count: chats.length,
149+
chats,
150+
command: [result.command, ...result.commandArgs],
151+
});
152+
},
153+
});
154+
api.registerTool({
155+
name: "whatsapp_messages_search",
156+
label: "Search WhatsApp messages",
157+
description: "Search synced WhatsApp messages by query, chat JID, or date range",
158+
parameters: Type.Object({
159+
query: Type.String({ minLength: 1 }),
160+
chat: Type.Optional(Type.String({ minLength: 1 })),
161+
after: Type.Optional(Type.String({ minLength: 1 })),
162+
before: Type.Optional(Type.String({ minLength: 1 })),
163+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
164+
}),
165+
async execute(_id, params) {
166+
const result = await runWacli(config, [
167+
"messages",
168+
"search",
169+
params.query,
170+
"--limit",
171+
String(params.limit ?? config.defaultMessageSearchLimit ?? DEFAULT_MESSAGE_SEARCH_LIMIT),
172+
...(params.chat ? ["--chat", params.chat] : []),
173+
...(params.after ? ["--after", params.after] : []),
174+
...(params.before ? ["--before", params.before] : []),
175+
], { json: true });
176+
const messages = ensureArray(result.parsed);
177+
return toolTextResult(formatMessageList(messages), {
178+
status: "ok",
179+
count: messages.length,
180+
messages,
181+
command: [result.command, ...result.commandArgs],
182+
});
183+
},
184+
});
185+
api.registerTool({
186+
name: "whatsapp_history_backfill",
187+
label: "Backfill WhatsApp history",
188+
description: "Request best-effort historical message sync for one chat from the linked phone",
189+
parameters: Type.Object({
190+
chat: Type.String({ minLength: 1 }),
191+
requests: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
192+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 500 })),
193+
}),
194+
async execute(_id, params) {
195+
const result = await runWacli(config, [
196+
"history",
197+
"backfill",
198+
"--chat",
199+
params.chat,
200+
"--requests",
201+
String(params.requests ?? 2),
202+
"--count",
203+
String(params.count ?? 50),
204+
]);
205+
return toolTextResult([`History backfill requested for ${params.chat}.`, result.stdout].filter(Boolean).join("\n"), {
206+
status: "ok",
207+
chat: params.chat,
208+
stdout: result.stdout,
209+
command: [result.command, ...result.commandArgs],
210+
});
211+
},
212+
});
213+
api.registerTool({
214+
name: "whatsapp_doctor",
215+
label: "Run WhatsApp diagnostics",
216+
description: "Run wacli doctor to inspect local WhatsApp CLI setup and auth state",
217+
parameters: Type.Object({}, { additionalProperties: false }),
218+
async execute() {
219+
const result = await runWacli(config, ["doctor"]);
220+
return toolTextResult(result.stdout || "wacli doctor completed.", {
221+
status: "ok",
222+
stdout: result.stdout,
223+
command: [result.command, ...result.commandArgs],
224+
});
225+
},
226+
});
227+
api.registerTool({
228+
name: "whatsapp_send_text",
229+
label: "Send WhatsApp text",
230+
description: "Send a WhatsApp text message to a verified phone number or chat JID",
231+
parameters: Type.Object({
232+
to: Type.String({ minLength: 1 }),
233+
message: Type.String({ minLength: 1 }),
234+
confirm: Type.Optional(Type.Boolean()),
235+
}),
236+
async execute(_id, params) {
237+
ensureSendConfirmed(config, params.confirm);
238+
const result = await runWacli(config, [
239+
"send",
240+
"text",
241+
"--to",
242+
params.to,
243+
"--message",
244+
params.message,
245+
]);
246+
return toolTextResult(result.stdout || `WhatsApp text sent to ${params.to}.`, {
247+
status: "sent",
248+
to: params.to,
249+
mode: "text",
250+
stdout: result.stdout,
251+
command: [result.command, ...result.commandArgs],
252+
});
253+
},
254+
});
255+
api.registerTool({
256+
name: "whatsapp_send_file",
257+
label: "Send WhatsApp file",
258+
description: "Send a file to a WhatsApp phone number or chat JID with an optional caption",
259+
parameters: Type.Object({
260+
to: Type.String({ minLength: 1 }),
261+
file: Type.String({ minLength: 1 }),
262+
caption: Type.Optional(Type.String()),
263+
filename: Type.Optional(Type.String()),
264+
confirm: Type.Optional(Type.Boolean()),
265+
}),
266+
async execute(_id, params) {
267+
ensureSendConfirmed(config, params.confirm);
268+
const result = await runWacli(config, [
269+
"send",
270+
"file",
271+
"--to",
272+
params.to,
273+
"--file",
274+
params.file,
275+
...(params.caption ? ["--caption", params.caption] : []),
276+
...(params.filename ? ["--filename", params.filename] : []),
277+
]);
278+
return toolTextResult(result.stdout || `WhatsApp file sent to ${params.to}.`, {
279+
status: "sent",
280+
to: params.to,
281+
mode: "file",
282+
file: params.file,
283+
stdout: result.stdout,
284+
command: [result.command, ...result.commandArgs],
285+
});
286+
},
287+
});
288+
},
7289
});

0 commit comments

Comments
 (0)