Skip to content

Commit bdfdd52

Browse files
committed
Add experimental WhatsApp Personal connector
Two-way WhatsApp on a personal account via a supervised Node.js sidecar built on Baileys (unofficial WhatsApp Web protocol). The sidecar streams inbound messages, pairing QRs, and connection state as NDJSON events on stdout — no inbound HTTP, no polling; its only listener is a loopback POST /send for the stateless send_message tool. Registration hooks (platforms, adapter factories, sender credentials) plug the experimental package into the existing gateway, which skips experimental platforms unless the opt-in setting is on. QR pairing via a new /v1/connectors/{name}/status endpoint; npm deps install into the state dir on first connect (never vendored); blunt account-ban risk notice gates connect; excluded from release builds like all experimental code.
1 parent 042a5b0 commit bdfdd52

14 files changed

Lines changed: 785 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 (default): the agent lives in your message-yourself thread. all: every chat on the account.",
40+
placeholder="self",
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 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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
/**
3+
* OpenCoworker WhatsApp Personal bridge — EXPERIMENTAL, use at your own risk.
4+
*
5+
* Speaks the unofficial WhatsApp Web protocol via Baileys and talks to the Python
6+
* adapter over a stdio event stream: every inbound message, QR refresh, and state
7+
* change is one NDJSON line on stdout, so there is no inbound HTTP surface, no
8+
* polling, and no message queue to overflow. The only network listener is a
9+
* single localhost POST /send endpoint, kept so the stateless send_message tool
10+
* can deliver replies without holding a handle to this process.
11+
*
12+
* stdout events (one JSON object per line):
13+
* {"event":"ready","port":N} HTTP send endpoint is up
14+
* {"event":"state","state":S,"me":J} S: pairing|open|closed|reconnecting
15+
* {"event":"qr","qr":"..."} pairing QR (refreshes periodically)
16+
* {"event":"message","id","chat","sender","name","group","text","ts"}
17+
*
18+
* Usage: node bridge.js --port 3941 --session <dir> --mode self|all
19+
*/
20+
21+
import http from "node:http";
22+
import { mkdirSync } from "node:fs";
23+
24+
import makeWASocket, {
25+
useMultiFileAuthState,
26+
fetchLatestBaileysVersion,
27+
DisconnectReason,
28+
} from "@whiskeysockets/baileys";
29+
30+
const opt = (flag, fallback) => {
31+
const i = process.argv.indexOf(`--${flag}`);
32+
return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
33+
};
34+
35+
const PORT = Number(opt("port", "3941"));
36+
const SESSION_DIR = opt("session", "./wa-session");
37+
const MODE = opt("mode", "self") === "all" ? "all" : "self";
38+
const TEXT_LIMIT = 4096;
39+
40+
const emit = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
41+
const digits = (jid) => String(jid || "").replace(/[:@].*$/, "").split(":")[0];
42+
43+
mkdirSync(SESSION_DIR, { recursive: true });
44+
45+
class WhatsAppLink {
46+
constructor() {
47+
this.sock = null;
48+
this.me = null;
49+
this.ownSends = [];
50+
}
51+
52+
rememberSend(id) {
53+
if (!id) return;
54+
this.ownSends.push(id);
55+
if (this.ownSends.length > 1000) this.ownSends.shift();
56+
}
57+
58+
textOf(m) {
59+
const c = m.message || {};
60+
return (
61+
c.conversation ||
62+
c.extendedTextMessage?.text ||
63+
c.imageMessage?.caption ||
64+
c.videoMessage?.caption ||
65+
""
66+
);
67+
}
68+
69+
// mode "self": only the message-yourself thread; user's own messages are the input,
70+
// but anything this bridge itself sent must never loop back in.
71+
shouldForward(m) {
72+
const chat = m.key.remoteJid || "";
73+
if (!chat || chat === "status@broadcast") return false;
74+
const isSelfThread = digits(chat) === digits(this.me);
75+
if (MODE === "self" && !isSelfThread) return false;
76+
if (m.key.fromMe) {
77+
return isSelfThread && !this.ownSends.includes(m.key.id);
78+
}
79+
return true;
80+
}
81+
82+
async open() {
83+
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
84+
const { version } = await fetchLatestBaileysVersion().catch(() => ({}));
85+
this.sock = makeWASocket({ auth: state, version, printQRInTerminal: false });
86+
this.sock.ev.on("creds.update", saveCreds);
87+
88+
this.sock.ev.on("connection.update", (u) => {
89+
if (u.qr) emit({ event: "qr", qr: u.qr }), emit({ event: "state", state: "pairing", me: null });
90+
if (u.connection === "open") {
91+
this.me = this.sock.user?.id || null;
92+
emit({ event: "state", state: "open", me: digits(this.me) });
93+
}
94+
if (u.connection === "close") {
95+
const code = u.lastDisconnect?.error?.output?.statusCode;
96+
if (code === DisconnectReason.loggedOut) {
97+
emit({ event: "state", state: "closed", me: null });
98+
} else {
99+
emit({ event: "state", state: "reconnecting", me: null });
100+
setTimeout(() => this.open().catch((e) => emit({ event: "state", state: "closed", error: String(e) })), 2500);
101+
}
102+
}
103+
});
104+
105+
this.sock.ev.on("messages.upsert", ({ messages, type }) => {
106+
if (type !== "notify") return;
107+
for (const m of messages) {
108+
const text = this.textOf(m);
109+
if (!text || !this.shouldForward(m)) continue;
110+
emit({
111+
event: "message",
112+
id: m.key.id || "",
113+
chat: m.key.remoteJid || "",
114+
sender: digits(m.key.fromMe ? this.me : m.key.participant || m.key.remoteJid),
115+
name: m.pushName || "",
116+
group: String(m.key.remoteJid || "").endsWith("@g.us"),
117+
text,
118+
ts: Number(m.messageTimestamp) || 0,
119+
});
120+
}
121+
});
122+
}
123+
124+
async deliver(to, body) {
125+
if (!this.sock || !this.me) throw new Error("not paired/connected yet");
126+
const sent = await this.sock.sendMessage(String(to), {
127+
text: String(body).slice(0, TEXT_LIMIT),
128+
});
129+
this.rememberSend(sent?.key?.id);
130+
return sent?.key?.id || "";
131+
}
132+
}
133+
134+
const link = new WhatsAppLink();
135+
136+
const server = http.createServer((req, res) => {
137+
const reply = (code, body) => {
138+
res.writeHead(code, { "content-type": "application/json" });
139+
res.end(JSON.stringify(body));
140+
};
141+
if (req.method !== "POST" || req.url !== "/send") return reply(404, { sent: false, error: "unknown route" });
142+
let raw = "";
143+
req.on("data", (chunk) => (raw += chunk));
144+
req.on("end", async () => {
145+
try {
146+
const { to, body } = JSON.parse(raw || "{}");
147+
if (!to || !body) return reply(400, { sent: false, error: "to and body required" });
148+
reply(200, { sent: true, id: await link.deliver(to, body) });
149+
} catch (e) {
150+
reply(500, { sent: false, error: String(e?.message || e) });
151+
}
152+
});
153+
});
154+
155+
// loopback only: the send endpoint must never be reachable off-machine
156+
server.listen(PORT, "127.0.0.1", () => {
157+
emit({ event: "ready", port: PORT });
158+
link.open().catch((e) => {
159+
emit({ event: "state", state: "closed", error: String(e?.message || e) });
160+
process.exit(1);
161+
});
162+
});
163+
164+
for (const sig of ["SIGINT", "SIGTERM"]) {
165+
process.on(sig, () => process.exit(0));
166+
}
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)