Skip to content

Commit f494bd5

Browse files
committed
Add experimental WhatsApp Personal connector (Baileys bridge)
Two-way WhatsApp on a personal account via a supervised Node.js bridge speaking the unofficial WhatsApp Web protocol (architecture credited to the MIT-licensed Hermes Agent bridge). The adapter stages the bridge into the state dir, installs npm deps on first connect (never vendored), long-polls inbound, and exposes pairing status/QR via a new /v1/connectors/{name}/status endpoint. Registration hooks (platforms, adapter factories, sender credentials) let the experimental package plug into the existing gateway; the gateway skips experimental platforms unless the opt-in setting is on, and the blunt account-ban risk notice gates connect. Excluded from release builds like all experimental code.
1 parent 042a5b0 commit f494bd5

14 files changed

Lines changed: 781 additions & 17 deletions

File tree

platform/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ __pycache__/
66
build/
77
dist/
88
.coverage
9+
10+
# experimental WhatsApp bridge — deps are installed into the state dir, never vendored
11+
coworker/connectors/experimental/whatsapp_bridge/node_modules/
12+
coworker/connectors/experimental/whatsapp_bridge/package-lock.json

platform/coworker/connectors/adapters.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,20 @@ async def send(
174174
return _send_slack(self.bot_token, chat_id, text, thread_id)
175175

176176

177+
ADAPTER_FACTORIES: dict[str, Any] = {}
178+
179+
180+
def register_adapter_factory(platform: str, factory: Any) -> None:
181+
"""Register a `profile -> Optional[BasePlatformAdapter]` factory for an extra
182+
platform (used by the experimental package)."""
183+
ADAPTER_FACTORIES[platform] = factory
184+
185+
177186
def make_adapter(platform: str, profile: dict) -> Optional[BasePlatformAdapter]:
178187
"""Build the adapter for a connected platform from its SecretStore profile."""
179188
if platform == "telegram" and profile.get("bot_token"):
180189
return TelegramAdapter(profile["bot_token"])
181190
if platform == "slack" and profile.get("bot_token") and profile.get("app_token"):
182191
return SlackAdapter(profile["bot_token"], profile["app_token"])
183-
return None
192+
factory = ADAPTER_FACTORIES.get(platform)
193+
return factory(profile) if factory is not None else None

platform/coworker/connectors/config.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,24 @@
1414
from ..secrets import SecretStore
1515
from .base import SessionSource
1616

17-
PLATFORMS = ("telegram", "slack")
17+
PLATFORMS: list[str] = ["telegram", "slack"]
18+
19+
# Per-platform credential key in the SecretStore profile that proves "connected enough to
20+
# listen". None → the platform needs no stored credential (e.g. QR-paired bridges); a bare
21+
# profile written by connect_connector is enough.
22+
_CREDENTIAL_KEYS: dict[str, Optional[str]] = {
23+
"telegram": "bot_token",
24+
"slack": "bot_token",
25+
}
26+
27+
28+
def register_platform(
29+
name: str, *, credential_key: Optional[str] = "bot_token"
30+
) -> None:
31+
"""Register an extra two-way platform (used by the experimental package)."""
32+
if name not in PLATFORMS:
33+
PLATFORMS.append(name)
34+
_CREDENTIAL_KEYS[name] = credential_key
1835

1936

2037
@dataclass
@@ -49,13 +66,14 @@ def load_settings(
4966
out: dict[str, ConnectorSettings] = {}
5067
for platform in PLATFORMS:
5168
profile = secrets.get(f"{platform}:default") or {}
52-
token = profile.get("bot_token")
69+
cred_key = _CREDENTIAL_KEYS.get(platform, "bot_token")
70+
has_cred = bool(profile.get(cred_key)) if cred_key else bool(profile)
5371
allowed = set(profile.get("allowed_users") or [])
5472
allowed |= _csv(os.environ.get(f"{platform.upper()}_ALLOWED_USERS"))
5573
allow_all = bool(profile.get("allow_all")) or os.environ.get(
5674
f"{platform.upper()}_ALLOW_ALL_USERS", ""
5775
).lower() in ("1", "true", "yes")
58-
enabled = bool(token) and profile.get("enabled", True)
76+
enabled = has_cred and profile.get("enabled", True)
5977
out[platform] = ConnectorSettings(
6078
platform=platform,
6179
enabled=enabled,

platform/coworker/connectors/experimental/__init__.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,76 @@
66
them in a self-built binary).
77
88
To add one: define a `ConnectorDescriptor` with a `risk_notice` that states the concrete
9-
downside in plain language, append it to `EXPERIMENTAL_DESCRIPTORS`, and register its tools or
10-
adapter the same way first-party connectors do. The `experimental` flag is forced on by the
11-
loader in descriptors.py regardless of what the descriptor sets.
9+
downside in plain language, append it to `EXPERIMENTAL_DESCRIPTORS`, and register its
10+
platform/adapter/sender via the registries in config.py, adapters.py, and senders.py. The
11+
`experimental` flag is forced on by the loader in descriptors.py regardless of what the
12+
descriptor sets.
1213
"""
1314

1415
from __future__ import annotations
1516

16-
from ..descriptors import ConnectorDescriptor
17+
from ..adapters import register_adapter_factory
18+
from ..config import register_platform
19+
from ..descriptors import ConnectorDescriptor, Field
20+
from ..senders import register_sender
21+
from .whatsapp_personal import (
22+
PLATFORM as _WA_PLATFORM,
23+
WhatsAppPersonalAdapter,
24+
send_whatsapp_personal,
25+
)
1726

18-
EXPERIMENTAL_DESCRIPTORS: list[ConnectorDescriptor] = []
27+
WHATSAPP_PERSONAL = ConnectorDescriptor(
28+
name=_WA_PLATFORM,
29+
title="WhatsApp Personal",
30+
icon="◌",
31+
blurb="Two-way WhatsApp on your personal account via the unofficial WhatsApp Web protocol.",
32+
auth="qr",
33+
two_way=True,
34+
fields=[
35+
Field(
36+
"mode",
37+
"Mode",
38+
required=False,
39+
help="self-chat (default): the agent lives in your message-yourself thread. bot: a dedicated number.",
40+
placeholder="self-chat",
41+
),
42+
Field(
43+
"bridge_port",
44+
"Bridge port",
45+
required=False,
46+
help="Local port for the bridge process. Default 3941.",
47+
placeholder="3941",
48+
),
49+
Field(
50+
"allowed_users",
51+
"Allowed user IDs",
52+
required=False,
53+
help="Comma-separated phone numbers (digits only) allowed to message the agent. Empty = nobody (self-chat mode only needs your own).",
54+
placeholder="14155550123",
55+
),
56+
],
57+
instructions=[
58+
"Requires Node.js on this machine — the bridge installs its own dependencies on first start.",
59+
"Use a SECONDARY phone number. Never pair your primary personal account.",
60+
"Connect here, then start the super-agent: the bridge starts and shows a QR code in the connector status.",
61+
"Scan it from WhatsApp → Settings → Linked devices → Link a device.",
62+
"In self-chat mode, talk to the agent in your own message-yourself thread.",
63+
],
64+
risk_notice=(
65+
"This connector speaks the unofficial WhatsApp Web protocol (Baileys). That violates "
66+
"WhatsApp's Terms of Service, and Meta detects and permanently bans accounts using it, "
67+
"without warning and without appeal. Only use a secondary number you can afford to "
68+
"lose. Nothing about this is endorsed by or affiliated with WhatsApp/Meta."
69+
),
70+
)
71+
72+
register_platform(_WA_PLATFORM, credential_key=None)
73+
register_adapter_factory(_WA_PLATFORM, WhatsAppPersonalAdapter)
74+
register_sender(
75+
_WA_PLATFORM,
76+
send_whatsapp_personal,
77+
credential_key="bridge_port",
78+
credential_required=False,
79+
)
80+
81+
EXPERIMENTAL_DESCRIPTORS: list[ConnectorDescriptor] = [WHATSAPP_PERSONAL]
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env node
2+
/**
3+
* OpenCoworker WhatsApp Personal bridge — EXPERIMENTAL, use at your own risk.
4+
*
5+
* Connects to WhatsApp as a personal account over the unofficial WhatsApp Web protocol
6+
* (Baileys) and exposes a localhost-only HTTP API for the Python adapter. Architecture
7+
* modeled on the Hermes Agent bridge (MIT, (c) 2025 Nous Research), reimplemented with a
8+
* smaller surface and no Express dependency.
9+
*
10+
* Endpoints:
11+
* GET /health -> { ok, status, me } status: starting|pairing|open|closed
12+
* GET /qr -> { qr } raw QR string while pairing, else null
13+
* GET /messages -> { messages: [...] } drains the inbound queue (long-poll ~25s)
14+
* POST /send -> { ok, messageId } body: { chatId, text }
15+
*
16+
* Usage: node bridge.js --port 3941 --session <dir> --mode self-chat|bot
17+
*/
18+
19+
import http from "node:http";
20+
import { mkdirSync } from "node:fs";
21+
22+
import makeWASocket, {
23+
useMultiFileAuthState,
24+
fetchLatestBaileysVersion,
25+
DisconnectReason,
26+
} from "@whiskeysockets/baileys";
27+
28+
const args = process.argv.slice(2);
29+
const getArg = (name, dflt) => {
30+
const i = args.indexOf(`--${name}`);
31+
return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
32+
};
33+
34+
const PORT = parseInt(getArg("port", "3941"), 10);
35+
const SESSION_DIR = getArg("session", "./wa-session");
36+
const MODE = getArg("mode", "self-chat"); // "self-chat": only your message-yourself thread
37+
const MAX_TEXT = 4096;
38+
const QUEUE_CAP = 500;
39+
const LONG_POLL_MS = 25000;
40+
41+
mkdirSync(SESSION_DIR, { recursive: true });
42+
43+
let sock = null;
44+
let status = "starting";
45+
let qrString = null;
46+
let meJid = null;
47+
const inbound = []; // queued message dicts for the Python adapter
48+
const sentByBridge = new Set(); // ids of our own sends, to drop echoes in self-chat mode
49+
let waiters = []; // pending long-poll resolvers
50+
51+
const bareJid = (jid) => String(jid || "").split(":")[0].split("@")[0];
52+
53+
function pushInbound(msg) {
54+
inbound.push(msg);
55+
if (inbound.length > QUEUE_CAP) inbound.shift();
56+
for (const w of waiters.splice(0)) w();
57+
}
58+
59+
function mapMessage(m) {
60+
const text =
61+
m.message?.conversation ||
62+
m.message?.extendedTextMessage?.text ||
63+
m.message?.imageMessage?.caption ||
64+
"";
65+
if (!text) return null;
66+
const chatId = m.key.remoteJid || "";
67+
if (chatId === "status@broadcast") return null;
68+
const fromMe = Boolean(m.key.fromMe);
69+
const selfChat = bareJid(chatId) === bareJid(meJid);
70+
if (MODE === "self-chat" && !selfChat) return null;
71+
// In self-chat the user's own messages are fromMe — keep them, but never our own sends.
72+
if (fromMe && (!selfChat || sentByBridge.has(m.key.id))) return null;
73+
return {
74+
id: m.key.id || "",
75+
chatId,
76+
senderId: bareJid(fromMe ? meJid : m.key.participant || chatId),
77+
senderName: m.pushName || "",
78+
isGroup: chatId.endsWith("@g.us"),
79+
text,
80+
timestamp: Number(m.messageTimestamp) || 0,
81+
};
82+
}
83+
84+
async function startSocket() {
85+
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
86+
const { version } = await fetchLatestBaileysVersion().catch(() => ({ version: undefined }));
87+
sock = makeWASocket({ auth: state, version, printQRInTerminal: false });
88+
89+
sock.ev.on("creds.update", saveCreds);
90+
sock.ev.on("connection.update", ({ connection, lastDisconnect, qr }) => {
91+
if (qr) {
92+
qrString = qr;
93+
status = "pairing";
94+
console.log("[bridge] pairing required — fetch GET /qr and scan it in WhatsApp");
95+
}
96+
if (connection === "open") {
97+
qrString = null;
98+
status = "open";
99+
meJid = sock.user?.id || null;
100+
console.log(`[bridge] connected as ${meJid}`);
101+
}
102+
if (connection === "close") {
103+
const code = lastDisconnect?.error?.output?.statusCode;
104+
if (code === DisconnectReason.loggedOut) {
105+
status = "closed";
106+
console.error("[bridge] logged out — delete the session dir and re-pair");
107+
} else {
108+
status = "starting";
109+
console.log(`[bridge] connection closed (code ${code}) — reconnecting`);
110+
setTimeout(() => startSocket().catch((e) => console.error("[bridge]", e)), 2000);
111+
}
112+
}
113+
});
114+
sock.ev.on("messages.upsert", ({ messages, type }) => {
115+
if (type !== "notify") return;
116+
for (const m of messages) {
117+
const mapped = mapMessage(m);
118+
if (mapped) pushInbound(mapped);
119+
}
120+
});
121+
}
122+
123+
const json = (res, code, body) => {
124+
res.writeHead(code, { "content-type": "application/json" });
125+
res.end(JSON.stringify(body));
126+
};
127+
128+
const readBody = (req) =>
129+
new Promise((resolve, reject) => {
130+
let data = "";
131+
req.on("data", (c) => {
132+
data += c;
133+
if (data.length > 1e6) reject(new Error("body too large"));
134+
});
135+
req.on("end", () => resolve(data));
136+
req.on("error", reject);
137+
});
138+
139+
const server = http.createServer(async (req, res) => {
140+
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
141+
try {
142+
if (req.method === "GET" && url.pathname === "/health") {
143+
return json(res, 200, { ok: true, status, me: bareJid(meJid) || null });
144+
}
145+
if (req.method === "GET" && url.pathname === "/qr") {
146+
return json(res, 200, { qr: qrString });
147+
}
148+
if (req.method === "GET" && url.pathname === "/messages") {
149+
if (!inbound.length) {
150+
await new Promise((resolve) => {
151+
const t = setTimeout(resolve, LONG_POLL_MS);
152+
waiters.push(() => {
153+
clearTimeout(t);
154+
resolve();
155+
});
156+
});
157+
}
158+
return json(res, 200, { messages: inbound.splice(0) });
159+
}
160+
if (req.method === "POST" && url.pathname === "/send") {
161+
const { chatId, text } = JSON.parse((await readBody(req)) || "{}");
162+
if (!chatId || !text) return json(res, 400, { ok: false, error: "chatId and text required" });
163+
if (status !== "open") return json(res, 503, { ok: false, error: `not connected (${status})` });
164+
const sent = await sock.sendMessage(String(chatId), { text: String(text).slice(0, MAX_TEXT) });
165+
const id = sent?.key?.id || "";
166+
if (id) {
167+
sentByBridge.add(id);
168+
if (sentByBridge.size > 1000) sentByBridge.delete(sentByBridge.values().next().value);
169+
}
170+
return json(res, 200, { ok: true, messageId: id });
171+
}
172+
return json(res, 404, { ok: false, error: "not found" });
173+
} catch (e) {
174+
return json(res, 500, { ok: false, error: String(e?.message || e) });
175+
}
176+
});
177+
178+
// localhost only — never expose the bridge beyond the machine
179+
server.listen(PORT, "127.0.0.1", () => {
180+
console.log(`[bridge] listening on 127.0.0.1:${PORT} (mode=${MODE})`);
181+
startSocket().catch((e) => {
182+
console.error("[bridge] fatal:", e);
183+
process.exit(1);
184+
});
185+
});
186+
187+
for (const sig of ["SIGINT", "SIGTERM"]) {
188+
process.on(sig, () => {
189+
try {
190+
server.close();
191+
sock?.end?.(undefined);
192+
} finally {
193+
process.exit(0);
194+
}
195+
});
196+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "coworker-whatsapp-bridge",
3+
"version": "0.1.0",
4+
"description": "EXPERIMENTAL WhatsApp personal-account bridge for OpenCoworker (unofficial protocol — use at your own risk)",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"start": "node bridge.js"
9+
},
10+
"dependencies": {
11+
"@whiskeysockets/baileys": "^6.7.9"
12+
}
13+
}

0 commit comments

Comments
 (0)