Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions platform/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ __pycache__/
build/
dist/
.coverage

# experimental WhatsApp bridge — deps are installed into the state dir, never vendored
coworker/connectors/experimental/whatsapp_bridge/node_modules/
coworker/connectors/experimental/whatsapp_bridge/package-lock.json
12 changes: 11 additions & 1 deletion platform/coworker/connectors/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,20 @@ async def send(
return _send_slack(self.bot_token, chat_id, text, thread_id)


ADAPTER_FACTORIES: dict[str, Any] = {}


def register_adapter_factory(platform: str, factory: Any) -> None:
"""Register a `profile -> Optional[BasePlatformAdapter]` factory for an extra
platform (used by the experimental package)."""
ADAPTER_FACTORIES[platform] = factory


def make_adapter(platform: str, profile: dict) -> Optional[BasePlatformAdapter]:
"""Build the adapter for a connected platform from its SecretStore profile."""
if platform == "telegram" and profile.get("bot_token"):
return TelegramAdapter(profile["bot_token"])
if platform == "slack" and profile.get("bot_token") and profile.get("app_token"):
return SlackAdapter(profile["bot_token"], profile["app_token"])
return None
factory = ADAPTER_FACTORIES.get(platform)
return factory(profile) if factory is not None else None
24 changes: 21 additions & 3 deletions platform/coworker/connectors/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@
from ..secrets import SecretStore
from .base import SessionSource

PLATFORMS = ("telegram", "slack")
PLATFORMS: list[str] = ["telegram", "slack"]

# Per-platform credential key in the SecretStore profile that proves "connected enough to
# listen". None → the platform needs no stored credential (e.g. QR-paired bridges); a bare
# profile written by connect_connector is enough.
_CREDENTIAL_KEYS: dict[str, Optional[str]] = {
"telegram": "bot_token",
"slack": "bot_token",
}


def register_platform(
name: str, *, credential_key: Optional[str] = "bot_token"
) -> None:
"""Register an extra two-way platform (used by the experimental package)."""
if name not in PLATFORMS:
PLATFORMS.append(name)
_CREDENTIAL_KEYS[name] = credential_key


@dataclass
Expand Down Expand Up @@ -49,13 +66,14 @@ def load_settings(
out: dict[str, ConnectorSettings] = {}
for platform in PLATFORMS:
profile = secrets.get(f"{platform}:default") or {}
token = profile.get("bot_token")
cred_key = _CREDENTIAL_KEYS.get(platform, "bot_token")
has_cred = bool(profile.get(cred_key)) if cred_key else bool(profile)
allowed = set(profile.get("allowed_users") or [])
allowed |= _csv(os.environ.get(f"{platform.upper()}_ALLOWED_USERS"))
allow_all = bool(profile.get("allow_all")) or os.environ.get(
f"{platform.upper()}_ALLOW_ALL_USERS", ""
).lower() in ("1", "true", "yes")
enabled = bool(token) and profile.get("enabled", True)
enabled = has_cred and profile.get("enabled", True)
out[platform] = ConnectorSettings(
platform=platform,
enabled=enabled,
Expand Down
73 changes: 68 additions & 5 deletions platform/coworker/connectors/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,76 @@
them in a self-built binary).

To add one: define a `ConnectorDescriptor` with a `risk_notice` that states the concrete
downside in plain language, append it to `EXPERIMENTAL_DESCRIPTORS`, and register its tools or
adapter the same way first-party connectors do. The `experimental` flag is forced on by the
loader in descriptors.py regardless of what the descriptor sets.
downside in plain language, append it to `EXPERIMENTAL_DESCRIPTORS`, and register its
platform/adapter/sender via the registries in config.py, adapters.py, and senders.py. The
`experimental` flag is forced on by the loader in descriptors.py regardless of what the
descriptor sets.
"""

from __future__ import annotations

from ..descriptors import ConnectorDescriptor
from ..adapters import register_adapter_factory
from ..config import register_platform
from ..descriptors import ConnectorDescriptor, Field
from ..senders import register_sender
from .whatsapp_personal import (
PLATFORM as _WA_PLATFORM,
WhatsAppPersonalAdapter,
send_whatsapp_personal,
)

EXPERIMENTAL_DESCRIPTORS: list[ConnectorDescriptor] = []
WHATSAPP_PERSONAL = ConnectorDescriptor(
name=_WA_PLATFORM,
title="WhatsApp Personal",
icon="◌",
blurb="Two-way WhatsApp on your personal account via the unofficial WhatsApp Web protocol.",
auth="qr",
two_way=True,
fields=[
Field(
"mode",
"Mode",
required=False,
help="self (default): the agent lives in your message-yourself thread. all: every chat on the account.",
placeholder="self",
),
Field(
"bridge_port",
"Bridge port",
required=False,
help="Local port for the bridge process. Default 3941.",
placeholder="3941",
),
Field(
"allowed_users",
"Allowed user IDs",
required=False,
help="Comma-separated phone numbers (digits only) allowed to message the agent. Empty = nobody (self-chat mode only needs your own).",
placeholder="14155550123",
),
],
instructions=[
"Requires Node.js on this machine — the bridge installs its own dependencies on first start.",
"Use a SECONDARY phone number. Never pair your primary personal account.",
"Connect here, then start the super-agent: the bridge starts and shows a QR code in the connector status.",
"Scan it from WhatsApp → Settings → Linked devices → Link a device.",
"In self mode, talk to the agent in your own message-yourself thread.",
],
risk_notice=(
"This connector speaks the unofficial WhatsApp Web protocol (Baileys). That violates "
"WhatsApp's Terms of Service, and Meta detects and permanently bans accounts using it, "
"without warning and without appeal. Only use a secondary number you can afford to "
"lose. Nothing about this is endorsed by or affiliated with WhatsApp/Meta."
),
)

register_platform(_WA_PLATFORM, credential_key=None)
register_adapter_factory(_WA_PLATFORM, WhatsAppPersonalAdapter)
register_sender(
_WA_PLATFORM,
send_whatsapp_personal,
credential_key="bridge_port",
credential_required=False,
)

EXPERIMENTAL_DESCRIPTORS: list[ConnectorDescriptor] = [WHATSAPP_PERSONAL]
166 changes: 166 additions & 0 deletions platform/coworker/connectors/experimental/whatsapp_bridge/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* OpenCoworker WhatsApp Personal bridge — EXPERIMENTAL, use at your own risk.
*
* Speaks the unofficial WhatsApp Web protocol via Baileys and talks to the Python
* adapter over a stdio event stream: every inbound message, QR refresh, and state
* change is one NDJSON line on stdout, so there is no inbound HTTP surface, no
* polling, and no message queue to overflow. The only network listener is a
* single localhost POST /send endpoint, kept so the stateless send_message tool
* can deliver replies without holding a handle to this process.
*
* stdout events (one JSON object per line):
* {"event":"ready","port":N} HTTP send endpoint is up
* {"event":"state","state":S,"me":J} S: pairing|open|closed|reconnecting
* {"event":"qr","qr":"..."} pairing QR (refreshes periodically)
* {"event":"message","id","chat","sender","name","group","text","ts"}
*
* Usage: node bridge.js --port 3941 --session <dir> --mode self|all
*/

import http from "node:http";
import { mkdirSync } from "node:fs";

import makeWASocket, {
useMultiFileAuthState,
fetchLatestBaileysVersion,
DisconnectReason,
} from "@whiskeysockets/baileys";

const opt = (flag, fallback) => {
const i = process.argv.indexOf(`--${flag}`);
return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
};

const PORT = Number(opt("port", "3941"));
const SESSION_DIR = opt("session", "./wa-session");
const MODE = opt("mode", "self") === "all" ? "all" : "self";
const TEXT_LIMIT = 4096;

const emit = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
const digits = (jid) => String(jid || "").replace(/[:@].*$/, "").split(":")[0];

mkdirSync(SESSION_DIR, { recursive: true });

class WhatsAppLink {
constructor() {
this.sock = null;
this.me = null;
this.ownSends = [];
}

rememberSend(id) {
if (!id) return;
this.ownSends.push(id);
if (this.ownSends.length > 1000) this.ownSends.shift();
}

textOf(m) {
const c = m.message || {};
return (
c.conversation ||
c.extendedTextMessage?.text ||
c.imageMessage?.caption ||
c.videoMessage?.caption ||
""
);
}

// mode "self": only the message-yourself thread; user's own messages are the input,
// but anything this bridge itself sent must never loop back in.
shouldForward(m) {
const chat = m.key.remoteJid || "";
if (!chat || chat === "status@broadcast") return false;
const isSelfThread = digits(chat) === digits(this.me);
if (MODE === "self" && !isSelfThread) return false;
if (m.key.fromMe) {
return isSelfThread && !this.ownSends.includes(m.key.id);
}
return true;
}

async open() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion().catch(() => ({}));
this.sock = makeWASocket({ auth: state, version, printQRInTerminal: false });
this.sock.ev.on("creds.update", saveCreds);

this.sock.ev.on("connection.update", (u) => {
if (u.qr) emit({ event: "qr", qr: u.qr }), emit({ event: "state", state: "pairing", me: null });
if (u.connection === "open") {
this.me = this.sock.user?.id || null;
emit({ event: "state", state: "open", me: digits(this.me) });
}
if (u.connection === "close") {
const code = u.lastDisconnect?.error?.output?.statusCode;
if (code === DisconnectReason.loggedOut) {
emit({ event: "state", state: "closed", me: null });
} else {
emit({ event: "state", state: "reconnecting", me: null });
setTimeout(() => this.open().catch((e) => emit({ event: "state", state: "closed", error: String(e) })), 2500);
}
}
});

this.sock.ev.on("messages.upsert", ({ messages, type }) => {
if (type !== "notify") return;
for (const m of messages) {
const text = this.textOf(m);
if (!text || !this.shouldForward(m)) continue;
emit({
event: "message",
id: m.key.id || "",
chat: m.key.remoteJid || "",
sender: digits(m.key.fromMe ? this.me : m.key.participant || m.key.remoteJid),
name: m.pushName || "",
group: String(m.key.remoteJid || "").endsWith("@g.us"),
text,
ts: Number(m.messageTimestamp) || 0,
});
}
});
}

async deliver(to, body) {
if (!this.sock || !this.me) throw new Error("not paired/connected yet");
const sent = await this.sock.sendMessage(String(to), {
text: String(body).slice(0, TEXT_LIMIT),
});
this.rememberSend(sent?.key?.id);
return sent?.key?.id || "";
}
}

const link = new WhatsAppLink();

const server = http.createServer((req, res) => {
const reply = (code, body) => {
res.writeHead(code, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (req.method !== "POST" || req.url !== "/send") return reply(404, { sent: false, error: "unknown route" });
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", async () => {
try {
const { to, body } = JSON.parse(raw || "{}");
if (!to || !body) return reply(400, { sent: false, error: "to and body required" });
reply(200, { sent: true, id: await link.deliver(to, body) });
} catch (e) {
reply(500, { sent: false, error: String(e?.message || e) });
}
});
});

// loopback only: the send endpoint must never be reachable off-machine
server.listen(PORT, "127.0.0.1", () => {
emit({ event: "ready", port: PORT });
link.open().catch((e) => {
emit({ event: "state", state: "closed", error: String(e?.message || e) });
process.exit(1);
});
});

for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => process.exit(0));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "coworker-whatsapp-bridge",
"version": "0.1.0",
"description": "EXPERIMENTAL WhatsApp personal-account bridge for OpenCoworker (unofficial protocol — use at your own risk)",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.9"
}
}
Loading
Loading