feat: add owpenbot whatsapp bridge (#214)

* feat: add owpenbot chat bridge

* docs: clarify owpenbot setup

* chore: refresh tauri lockfile

* docs: add owpenbot installer
This commit is contained in:
ben
2026-01-23 11:31:35 -08:00
committed by GitHub
parent 4348fe5617
commit 49a8501e2e
23 changed files with 2877 additions and 21 deletions

View File

@@ -20,6 +20,10 @@ Its a native desktop app that runs **OpenCode** under the hood, but presents
The goal: make “agentic work” feel like a product, not a terminal. The goal: make “agentic work” feel like a product, not a terminal.
## Alternate UIs
- **Owpenbot (WhatsApp bot)**: a lightweight WhatsApp bridge for a running OpenCode server. See `packages/owpenbot/README.md` for setup and the one-command installer.
## Quick start ## Quick start
Download the dmg here https://github.com/different-ai/openwork/releases (or install from source below) Download the dmg here https://github.com/different-ai/openwork/releases (or install from source below)

View File

@@ -28,7 +28,10 @@
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild" "@whiskeysockets/baileys",
"better-sqlite3",
"esbuild",
"protobufjs"
] ]
}, },
"packageManager": "pnpm@10.27.0" "packageManager": "pnpm@10.27.0"

View File

@@ -397,9 +397,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.53" version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -2381,7 +2381,7 @@ dependencies = [
[[package]] [[package]]
name = "openwork" name = "openwork"
version = "0.3.4" version = "0.3.5"
dependencies = [ dependencies = [
"json5", "json5",
"serde", "serde",
@@ -2827,9 +2827,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.105" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -2900,9 +2900,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.43" version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -3584,9 +3584,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.60.2",

View File

@@ -0,0 +1,96 @@
# Owpenbot
Simple WhatsApp bridge for a running OpenCode server. Telegram support exists but is not yet E2E tested.
## Install + Run (WhatsApp)
One-command install (recommended):
```bash
curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash
```
Then follow the printed next steps (edit `.env`, pair WhatsApp, start the bridge).
1) One-command setup (installs deps, builds, creates `.env` if missing):
```bash
pnpm -C packages/owpenbot setup
```
2) Fill in `packages/owpenbot/.env` (see `.env.example`).
Required:
- `OPENCODE_URL`
- `OPENCODE_DIRECTORY`
- `WHATSAPP_AUTH_DIR`
Recommended:
- `OPENCODE_SERVER_USERNAME`
- `OPENCODE_SERVER_PASSWORD`
3) Pair WhatsApp (first time only):
```bash
pnpm -C packages/owpenbot whatsapp:login
```
4) Launch the bridge:
```bash
pnpm -C packages/owpenbot start
```
5) Pair a user with the bot:
- Run `pnpm -C packages/owpenbot pairing-code` to get the code.
- Send a WhatsApp message containing the code (e.g. `123456 hello`).
- You should receive an OpenCode response in the same chat.
## Usage Flows
### One-person flow (personal testing)
Use your own WhatsApp account as the bot and test from a second number you control.
1) Pair WhatsApp using your personal number (`whatsapp:login`).
2) Send the pairing code from a second number (SIM/eSIM or another phone).
3) Chat from that second number to receive OpenCode replies.
Note: WhatsApps “message yourself” thread is not reliable for bot testing.
### Two-person flow (dedicated bot)
Use a separate WhatsApp number as the bot account so it stays independent from your personal chat history.
1) Create a new WhatsApp account for the dedicated number.
2) Pair that account with `whatsapp:login`.
3) Share the pairing code with the person who should use the bot.
4) Optionally pre-allowlist specific numbers with `ALLOW_FROM_WHATSAPP=`.
## Telegram (Untested)
Telegram support is wired but not E2E tested yet. To try it:
- Set `TELEGRAM_BOT_TOKEN`.
- Optionally set `TELEGRAM_ENABLED=true`.
## Commands
```bash
pnpm -C packages/owpenbot start
pnpm -C packages/owpenbot whatsapp:login
pnpm -C packages/owpenbot pairing-code
```
## Defaults
- SQLite at `~/.owpenbot/owpenbot.db` unless overridden.
- Allowlist is enforced by default; a pairing code is generated if not provided.
- Group chats are disabled unless `GROUPS_ENABLED=true`.
## Tests
```bash
pnpm -C packages/owpenbot test:unit
pnpm -C packages/owpenbot test:smoke
```

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
OWPENBOT_REF="${OWPENBOT_REF:-dev}"
OWPENBOT_REPO="${OWPENBOT_REPO:-https://github.com/different-ai/openwork.git}"
OWPENBOT_INSTALL_DIR="${OWPENBOT_INSTALL_DIR:-$HOME/.owpenbot/openwork}"
usage() {
cat <<'EOF'
Owpenbot installer (WhatsApp-first)
Environment variables:
OWPENBOT_INSTALL_DIR Install directory (default: ~/.owpenbot/openwork)
OWPENBOT_REPO Git repo (default: https://github.com/different-ai/openwork.git)
OWPENBOT_REF Git ref/branch (default: dev)
Example:
OWPENBOT_INSTALL_DIR=~/owpenbot curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash
EOF
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
require_bin() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing $1. Please install it and retry." >&2
exit 1
fi
}
require_bin git
require_bin node
if ! command -v pnpm >/dev/null 2>&1; then
if command -v corepack >/dev/null 2>&1; then
corepack enable >/dev/null 2>&1 || true
corepack prepare pnpm@10.27.0 --activate
else
echo "pnpm is required. Install pnpm or enable corepack, then retry." >&2
exit 1
fi
fi
if [[ -d "$OWPENBOT_INSTALL_DIR/.git" ]]; then
echo "Updating owpenbot source in $OWPENBOT_INSTALL_DIR"
git -C "$OWPENBOT_INSTALL_DIR" fetch origin --prune
git -C "$OWPENBOT_INSTALL_DIR" checkout "$OWPENBOT_REF"
git -C "$OWPENBOT_INSTALL_DIR" pull --ff-only origin "$OWPENBOT_REF"
else
echo "Cloning owpenbot source to $OWPENBOT_INSTALL_DIR"
mkdir -p "$OWPENBOT_INSTALL_DIR"
git clone --branch "$OWPENBOT_REF" --depth 1 "$OWPENBOT_REPO" "$OWPENBOT_INSTALL_DIR"
fi
echo "Installing dependencies..."
pnpm -C "$OWPENBOT_INSTALL_DIR" install
echo "Building owpenbot..."
pnpm -C "$OWPENBOT_INSTALL_DIR/packages/owpenbot" build
ENV_PATH="$OWPENBOT_INSTALL_DIR/packages/owpenbot/.env"
ENV_EXAMPLE="$OWPENBOT_INSTALL_DIR/packages/owpenbot/.env.example"
if [[ ! -f "$ENV_PATH" ]]; then
cp "$ENV_EXAMPLE" "$ENV_PATH"
echo "Created $ENV_PATH"
fi
cat <<EOF
Owpenbot installed.
Next steps:
1) Edit $ENV_PATH
2) Pair WhatsApp: pnpm -C $OWPENBOT_INSTALL_DIR/packages/owpenbot whatsapp:login
3) Start bridge: pnpm -C $OWPENBOT_INSTALL_DIR/packages/owpenbot start
EOF

View File

@@ -0,0 +1,37 @@
{
"name": "@different-ai/owpenbot",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"owpenbot": "dist/cli.js"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/cli.js start",
"whatsapp:login": "node dist/cli.js whatsapp login",
"pairing-code": "node dist/cli.js pairing-code",
"typecheck": "tsc -p tsconfig.json --noEmit",
"setup": "node scripts/setup.mjs",
"test:unit": "pnpm build && node --test test/*.test.js",
"test:smoke": "node scripts/smoke.mjs"
},
"dependencies": {
"@opencode-ai/sdk": "^1.1.19",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"better-sqlite3": "^11.7.0",
"commander": "^12.1.0",
"dotenv": "^16.4.7",
"grammy": "^1.39.3",
"pino": "^9.6.0",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.2",
"@types/qrcode-terminal": "^0.12.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,26 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(packageDir, "..", "..");
const envPath = path.join(packageDir, ".env");
const envExamplePath = path.join(packageDir, ".env.example");
const install = spawnSync("pnpm", ["install"], { cwd: rootDir, stdio: "inherit" });
if (install.status !== 0) {
process.exit(install.status ?? 1);
}
const build = spawnSync("pnpm", ["-C", packageDir, "build"], { cwd: rootDir, stdio: "inherit" });
if (build.status !== 0) {
process.exit(build.status ?? 1);
}
if (!existsSync(envPath) && existsSync(envExamplePath)) {
copyFileSync(envExamplePath, envPath);
console.log("Created .env from .env.example");
}

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
const args = new Set(process.argv.slice(2));
const requireReply = args.has("--reply");
const baseUrl = process.env.OPENCODE_URL ?? "http://127.0.0.1:4096";
const directory = process.env.OPENCODE_DIRECTORY ?? process.cwd();
const headers = {};
if (process.env.OPENCODE_SERVER_USERNAME && process.env.OPENCODE_SERVER_PASSWORD) {
const token = Buffer.from(
`${process.env.OPENCODE_SERVER_USERNAME}:${process.env.OPENCODE_SERVER_PASSWORD}`,
).toString("base64");
headers.Authorization = `Basic ${token}`;
}
const client = createOpencodeClient({
baseUrl,
directory,
headers: Object.keys(headers).length ? headers : undefined,
responseStyle: "data",
throwOnError: true,
});
const health = await client.global.health();
assert.equal(health.healthy, true);
const session = await client.session.create({ title: "owpenbot smoke" });
assert.ok(session?.id);
await client.session.prompt({
sessionID: session.id,
noReply: !requireReply,
parts: [{ type: "text", text: "ping" }],
});
const messages = await client.session.messages({ sessionID: session.id, limit: 20 });
assert.ok(Array.isArray(messages));
console.log(
JSON.stringify({
ok: true,
baseUrl,
directory,
sessionID: session.id,
messageCount: messages.length,
}),
);

View File

@@ -0,0 +1,293 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Logger } from "pino";
import type { Config, ChannelName } from "./config.js";
import { BridgeStore } from "./db.js";
import { normalizeEvent } from "./events.js";
import { startHealthServer, type HealthSnapshot } from "./health.js";
import { buildPermissionRules, createClient } from "./opencode.js";
import { resolvePairingCode } from "./pairing.js";
import { chunkText, formatInputSummary, truncateText } from "./text.js";
import { createTelegramAdapter } from "./telegram.js";
import { createWhatsAppAdapter } from "./whatsapp.js";
type Adapter = {
name: ChannelName;
maxTextLength: number;
start(): Promise<void>;
stop(): Promise<void>;
sendText(peerId: string, text: string): Promise<void>;
};
type InboundMessage = {
channel: ChannelName;
peerId: string;
text: string;
raw: unknown;
};
type RunState = {
sessionID: string;
channel: ChannelName;
peerId: string;
toolUpdatesEnabled: boolean;
seenToolStates: Map<string, string>;
};
const TOOL_LABELS: Record<string, string> = {
bash: "bash",
read: "read",
write: "write",
edit: "edit",
patch: "patch",
multiedit: "edit",
grep: "grep",
glob: "glob",
task: "agent",
webfetch: "webfetch",
};
export async function startBridge(config: Config, logger: Logger) {
const client = createClient(config);
const store = new BridgeStore(config.dbPath);
store.seedAllowlist("telegram", config.allowlist.telegram);
store.seedAllowlist("whatsapp", config.allowlist.whatsapp);
const pairingCode = resolvePairingCode(store, config.pairingCode);
logger.info({ pairingCode }, "pairing code ready");
const adapters = new Map<ChannelName, Adapter>();
if (config.telegramEnabled && config.telegramToken) {
adapters.set("telegram", createTelegramAdapter(config, logger, handleInbound));
} else {
logger.info("telegram adapter disabled");
}
if (config.whatsappEnabled) {
adapters.set("whatsapp", createWhatsAppAdapter(config, logger, handleInbound, { printQr: true }));
} else {
logger.info("whatsapp adapter disabled");
}
const sessionQueue = new Map<string, Promise<void>>();
const activeRuns = new Map<string, RunState>();
let opencodeHealthy = false;
let opencodeVersion: string | undefined;
async function refreshHealth() {
try {
const health = await client.global.health();
opencodeHealthy = Boolean((health as { healthy?: boolean }).healthy);
opencodeVersion = (health as { version?: string }).version;
} catch (error) {
logger.warn({ error }, "failed to reach opencode health");
opencodeHealthy = false;
}
}
await refreshHealth();
const healthTimer = setInterval(refreshHealth, 30_000);
let stopHealthServer: (() => void) | null = null;
if (config.healthPort) {
stopHealthServer = startHealthServer(
config.healthPort,
(): HealthSnapshot => ({
ok: opencodeHealthy,
opencode: {
url: config.opencodeUrl,
healthy: opencodeHealthy,
version: opencodeVersion,
},
channels: {
telegram: adapters.has("telegram"),
whatsapp: adapters.has("whatsapp"),
},
}),
logger,
);
}
const eventAbort = new AbortController();
void (async () => {
const subscription = await client.event.subscribe(undefined, { signal: eventAbort.signal });
for await (const raw of subscription.stream as AsyncIterable<unknown>) {
const event = normalizeEvent(raw as any);
if (!event) continue;
if (event.type === "message.part.updated") {
const part = (event.properties as { part?: any })?.part;
if (!part?.sessionID) continue;
const run = activeRuns.get(part.sessionID);
if (!run || !run.toolUpdatesEnabled) continue;
if (part.type !== "tool") continue;
const callId = part.callID as string | undefined;
if (!callId) continue;
const state = part.state as { status?: string; input?: Record<string, unknown>; output?: string; title?: string };
const status = state?.status ?? "unknown";
if (run.seenToolStates.get(callId) === status) continue;
run.seenToolStates.set(callId, status);
const label = TOOL_LABELS[part.tool] ?? part.tool;
const title = state.title || truncateText(formatInputSummary(state.input ?? {}), 120) || "running";
let message = `[tool] ${label} ${status}: ${title}`;
if (status === "completed" && state.output) {
const output = truncateText(state.output.trim(), config.toolOutputLimit);
if (output) message += `\n${output}`;
}
await sendText(run.channel, run.peerId, message);
}
if (event.type === "permission.asked") {
const permission = event.properties as { id?: string; sessionID?: string };
if (!permission?.id || !permission.sessionID) continue;
const response = config.permissionMode === "deny" ? "reject" : "always";
await client.permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response,
});
if (response === "reject") {
const run = activeRuns.get(permission.sessionID);
if (run) {
await sendText(run.channel, run.peerId, "Permission denied. Update configuration to allow tools.");
}
}
}
}
})().catch((error) => {
logger.error({ error }, "event stream closed");
});
async function sendText(channel: ChannelName, peerId: string, text: string) {
const adapter = adapters.get(channel);
if (!adapter) return;
const chunks = chunkText(text, adapter.maxTextLength);
for (const chunk of chunks) {
logger.info({ channel, peerId, length: chunk.length }, "sending message");
await adapter.sendText(peerId, chunk);
}
}
async function handleInbound(message: InboundMessage) {
const adapter = adapters.get(message.channel);
if (!adapter) return;
let inbound = message;
logger.info(
{ channel: inbound.channel, peerId: inbound.peerId, length: inbound.text.length },
"received message",
);
const allowed = store.isAllowed(inbound.channel, inbound.peerId);
if (!allowed) {
const trimmed = inbound.text.trim();
if (trimmed.includes(pairingCode)) {
store.allowPeer(inbound.channel, inbound.peerId);
const remaining = trimmed.replace(pairingCode, "").trim();
if (remaining) {
await sendText(inbound.channel, inbound.peerId, "Paired. Processing your message.");
} else {
await sendText(inbound.channel, inbound.peerId, "Paired. Send your message again.");
return;
}
inbound = { ...inbound, text: remaining };
} else {
await sendText(
inbound.channel,
inbound.peerId,
`Pairing required. Reply with code: ${pairingCode}`,
);
return;
}
}
const session = store.getSession(inbound.channel, inbound.peerId);
const sessionID = session?.session_id ?? (await createSession(inbound));
enqueue(sessionID, async () => {
const runState: RunState = {
sessionID,
channel: inbound.channel,
peerId: inbound.peerId,
toolUpdatesEnabled: config.toolUpdatesEnabled,
seenToolStates: new Map(),
};
activeRuns.set(sessionID, runState);
try {
const response = await client.session.prompt({
sessionID,
parts: [{ type: "text", text: inbound.text }],
});
const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? [];
const reply = parts
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => part.text ?? "")
.join("\n")
.trim();
if (reply) {
await sendText(inbound.channel, inbound.peerId, reply);
} else {
await sendText(inbound.channel, inbound.peerId, "No response generated. Try again.");
}
} catch (error) {
logger.error({ error }, "prompt failed");
await sendText(inbound.channel, inbound.peerId, "Error: failed to reach OpenCode.");
} finally {
activeRuns.delete(sessionID);
}
});
}
async function createSession(message: InboundMessage): Promise<string> {
const title = `owpenbot ${message.channel} ${message.peerId}`;
const session = await client.session.create({
title,
permission: buildPermissionRules(config.permissionMode),
});
const sessionID = (session as { id?: string }).id;
if (!sessionID) throw new Error("Failed to create session");
store.upsertSession(message.channel, message.peerId, sessionID);
logger.info({ sessionID, channel: message.channel, peerId: message.peerId }, "session created");
return sessionID;
}
function enqueue(sessionID: string, task: () => Promise<void>) {
const previous = sessionQueue.get(sessionID) ?? Promise.resolve();
const next = previous
.then(task)
.catch((error) => {
logger.error({ error }, "session task failed");
})
.finally(() => {
if (sessionQueue.get(sessionID) === next) {
sessionQueue.delete(sessionID);
}
});
sessionQueue.set(sessionID, next);
}
for (const adapter of adapters.values()) {
await adapter.start();
}
logger.info({ channels: Array.from(adapters.keys()) }, "bridge started");
return {
async stop() {
eventAbort.abort();
clearInterval(healthTimer);
if (stopHealthServer) stopHealthServer();
for (const adapter of adapters.values()) {
await adapter.stop();
}
store.close();
await delay(50);
},
};
}

View File

@@ -0,0 +1,54 @@
import { Command } from "commander";
import { startBridge } from "./bridge.js";
import { loadConfig } from "./config.js";
import { BridgeStore } from "./db.js";
import { createLogger } from "./logger.js";
import { resolvePairingCode } from "./pairing.js";
import { loginWhatsApp } from "./whatsapp.js";
const program = new Command();
program.name("owpenbot").description("OpenCode WhatsApp + Telegram bridge");
program
.command("start")
.description("Start the bridge")
.action(async () => {
const config = loadConfig();
const logger = createLogger(config.logLevel);
const bridge = await startBridge(config, logger);
const shutdown = async () => {
logger.info("shutting down");
await bridge.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
});
program
.command("pairing-code")
.description("Print the current pairing code")
.action(() => {
const config = loadConfig(process.env, { requireOpencode: false });
const store = new BridgeStore(config.dbPath);
const code = resolvePairingCode(store, config.pairingCode);
console.log(code);
store.close();
});
const whatsapp = program.command("whatsapp").description("WhatsApp helpers");
whatsapp
.command("login")
.description("Login to WhatsApp via QR code")
.action(async () => {
const config = loadConfig(process.env, { requireOpencode: false });
const logger = createLogger(config.logLevel);
await loginWhatsApp(config, logger);
});
await program.parseAsync(process.argv);

View File

@@ -0,0 +1,127 @@
import os from "node:os";
import path from "node:path";
import dotenv from "dotenv";
dotenv.config();
export type ChannelName = "telegram" | "whatsapp";
export type Config = {
opencodeUrl: string;
opencodeDirectory: string;
opencodeUsername?: string;
opencodePassword?: string;
telegramToken?: string;
telegramEnabled: boolean;
whatsappAuthDir: string;
whatsappEnabled: boolean;
dataDir: string;
dbPath: string;
allowlist: Record<ChannelName, Set<string>>;
pairingCode?: string;
toolUpdatesEnabled: boolean;
groupsEnabled: boolean;
permissionMode: "allow" | "deny";
toolOutputLimit: number;
healthPort?: number;
logLevel: string;
};
type EnvLike = NodeJS.ProcessEnv;
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}
function parseInteger(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseList(value: string | undefined): string[] {
if (!value) return [];
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function expandHome(value: string): string {
if (!value.startsWith("~/")) return value;
return path.join(os.homedir(), value.slice(2));
}
function parseAllowlist(env: EnvLike): Record<ChannelName, Set<string>> {
const allowlist: Record<ChannelName, Set<string>> = {
telegram: new Set<string>(),
whatsapp: new Set<string>(),
};
const shared = parseList(env.ALLOW_FROM);
for (const entry of shared) {
if (entry.includes(":")) {
const [channel, peer] = entry.split(":");
const normalized = channel.trim().toLowerCase();
if (normalized === "telegram" || normalized === "whatsapp") {
if (peer?.trim()) {
allowlist[normalized].add(peer.trim());
}
}
} else {
allowlist.telegram.add(entry);
allowlist.whatsapp.add(entry);
}
}
for (const entry of parseList(env.ALLOW_FROM_TELEGRAM)) {
allowlist.telegram.add(entry);
}
for (const entry of parseList(env.ALLOW_FROM_WHATSAPP)) {
allowlist.whatsapp.add(entry);
}
return allowlist;
}
export function loadConfig(
env: EnvLike = process.env,
options: { requireOpencode?: boolean } = {},
): Config {
const requireOpencode = options.requireOpencode ?? true;
const opencodeDirectory = env.OPENCODE_DIRECTORY?.trim();
if (!opencodeDirectory && requireOpencode) {
throw new Error("OPENCODE_DIRECTORY is required");
}
const resolvedDirectory = opencodeDirectory || process.cwd();
const dataDir = expandHome(env.OWPENBOT_DATA_DIR ?? "~/.owpenbot");
const dbPath = expandHome(env.OWPENBOT_DB_PATH ?? path.join(dataDir, "owpenbot.db"));
const whatsappAuthDir = expandHome(env.WHATSAPP_AUTH_DIR ?? path.join(dataDir, "whatsapp"));
const toolOutputLimit = parseInteger(env.TOOL_OUTPUT_LIMIT) ?? 1200;
const permissionMode = env.PERMISSION_MODE?.toLowerCase() === "deny" ? "deny" : "allow";
return {
opencodeUrl: env.OPENCODE_URL?.trim() ?? "http://127.0.0.1:4096",
opencodeDirectory: resolvedDirectory,
opencodeUsername: env.OPENCODE_SERVER_USERNAME?.trim() || undefined,
opencodePassword: env.OPENCODE_SERVER_PASSWORD?.trim() || undefined,
telegramToken: env.TELEGRAM_BOT_TOKEN?.trim() || undefined,
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, Boolean(env.TELEGRAM_BOT_TOKEN?.trim())),
whatsappAuthDir,
whatsappEnabled: parseBoolean(env.WHATSAPP_ENABLED, true),
dataDir,
dbPath,
allowlist: parseAllowlist(env),
pairingCode: env.PAIRING_CODE?.trim() || undefined,
toolUpdatesEnabled: parseBoolean(env.TOOL_UPDATES_ENABLED, false),
groupsEnabled: parseBoolean(env.GROUPS_ENABLED, false),
permissionMode,
toolOutputLimit,
healthPort: parseInteger(env.OWPENBOT_HEALTH_PORT),
logLevel: env.LOG_LEVEL?.trim() || "info",
};
}

122
packages/owpenbot/src/db.ts Normal file
View File

@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import type { ChannelName } from "./config.js";
type SessionRow = {
channel: ChannelName;
peer_id: string;
session_id: string;
created_at: number;
updated_at: number;
};
type AllowlistRow = {
channel: ChannelName;
peer_id: string;
created_at: number;
};
export class BridgeStore {
private db: Database.Database;
constructor(private readonly dbPath: string) {
this.ensureDir();
this.db = new Database(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
channel TEXT NOT NULL,
peer_id TEXT NOT NULL,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel, peer_id)
);
CREATE TABLE IF NOT EXISTS allowlist (
channel TEXT NOT NULL,
peer_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (channel, peer_id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
}
private ensureDir() {
const dir = path.dirname(this.dbPath);
fs.mkdirSync(dir, { recursive: true });
}
getSession(channel: ChannelName, peerId: string): SessionRow | null {
const stmt = this.db.prepare(
"SELECT channel, peer_id, session_id, created_at, updated_at FROM sessions WHERE channel = ? AND peer_id = ?",
);
const row = stmt.get(channel, peerId) as SessionRow | undefined;
return row ?? null;
}
upsertSession(channel: ChannelName, peerId: string, sessionId: string) {
const now = Date.now();
const stmt = this.db.prepare(
`INSERT INTO sessions (channel, peer_id, session_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(channel, peer_id) DO UPDATE SET session_id = excluded.session_id, updated_at = excluded.updated_at`,
);
stmt.run(channel, peerId, sessionId, now, now);
}
isAllowed(channel: ChannelName, peerId: string): boolean {
const stmt = this.db.prepare(
"SELECT channel, peer_id, created_at FROM allowlist WHERE channel = ? AND peer_id = ?",
);
return Boolean(stmt.get(channel, peerId));
}
allowPeer(channel: ChannelName, peerId: string) {
const now = Date.now();
const stmt = this.db.prepare(
`INSERT INTO allowlist (channel, peer_id, created_at)
VALUES (?, ?, ?)
ON CONFLICT(channel, peer_id) DO UPDATE SET created_at = excluded.created_at`,
);
stmt.run(channel, peerId, now);
}
seedAllowlist(channel: ChannelName, peers: Iterable<string>) {
const insert = this.db.prepare(
`INSERT INTO allowlist (channel, peer_id, created_at)
VALUES (?, ?, ?)
ON CONFLICT(channel, peer_id) DO NOTHING`,
);
const now = Date.now();
const transaction = this.db.transaction((items: Iterable<string>) => {
for (const peer of items) {
insert.run(channel, peer, now);
}
});
transaction(peers);
}
getSetting(key: string): string | null {
const stmt = this.db.prepare("SELECT value FROM settings WHERE key = ?");
const row = stmt.get(key) as { value?: string } | undefined;
return row?.value ?? null;
}
setSetting(key: string, value: string) {
const stmt = this.db.prepare(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
);
stmt.run(key, value);
}
close() {
this.db.close();
}
}

View File

@@ -0,0 +1,21 @@
type RawEvent = {
type?: string;
properties?: unknown;
payload?: { type?: string; properties?: unknown };
};
export type NormalizedEvent = {
type: string;
properties?: any;
};
export function normalizeEvent(raw: RawEvent | null | undefined): NormalizedEvent | null {
if (!raw) return null;
if (typeof raw.type === "string") {
return { type: raw.type, properties: raw.properties };
}
if (raw.payload && typeof raw.payload.type === "string") {
return { type: raw.payload.type, properties: raw.payload.properties };
}
return null;
}

View File

@@ -0,0 +1,44 @@
import http from "node:http";
import type { Logger } from "pino";
export type HealthSnapshot = {
ok: boolean;
opencode: {
url: string;
healthy: boolean;
version?: string;
};
channels: {
telegram: boolean;
whatsapp: boolean;
};
};
export function startHealthServer(
port: number,
getStatus: () => HealthSnapshot,
logger: Logger,
) {
const server = http.createServer((req, res) => {
if (!req.url || req.url === "/health") {
const snapshot = getStatus();
res.writeHead(snapshot.ok ? 200 : 503, {
"Content-Type": "application/json",
});
res.end(JSON.stringify(snapshot));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Not found" }));
});
server.listen(port, "0.0.0.0", () => {
logger.info({ port }, "health server listening");
});
return () => {
server.close();
};
}

View File

@@ -0,0 +1,8 @@
import pino from "pino";
export function createLogger(level: string) {
return pino({
level,
base: undefined,
});
}

View File

@@ -0,0 +1,43 @@
import { Buffer } from "node:buffer";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { Config } from "./config.js";
type Client = ReturnType<typeof createOpencodeClient>;
export function createClient(config: Config): Client {
const headers: Record<string, string> = {};
if (config.opencodeUsername && config.opencodePassword) {
const token = Buffer.from(`${config.opencodeUsername}:${config.opencodePassword}`).toString("base64");
headers.Authorization = `Basic ${token}`;
}
return createOpencodeClient({
baseUrl: config.opencodeUrl,
directory: config.opencodeDirectory,
headers: Object.keys(headers).length ? headers : undefined,
responseStyle: "data",
throwOnError: true,
});
}
export function buildPermissionRules(mode: Config["permissionMode"]) {
if (mode === "deny") {
return [
{
permission: "*",
pattern: "*",
action: "deny" as const,
},
];
}
return [
{
permission: "*",
pattern: "*",
action: "allow" as const,
},
];
}

View File

@@ -0,0 +1,19 @@
import { randomInt } from "node:crypto";
import { BridgeStore } from "./db.js";
const SETTING_KEY = "pairing_code";
export function resolvePairingCode(store: BridgeStore, override?: string): string {
if (override) {
store.setSetting(SETTING_KEY, override);
return override;
}
const existing = store.getSetting(SETTING_KEY);
if (existing) return existing;
const code = String(randomInt(100000, 999999));
store.setSetting(SETTING_KEY, code);
return code;
}

View File

@@ -0,0 +1,76 @@
import { Bot, type BotError, type Context } from "grammy";
import type { Logger } from "pino";
import type { Config } from "./config.js";
export type InboundMessage = {
channel: "telegram";
peerId: string;
text: string;
raw: unknown;
};
export type MessageHandler = (message: InboundMessage) => Promise<void> | void;
export type TelegramAdapter = {
name: "telegram";
maxTextLength: number;
start(): Promise<void>;
stop(): Promise<void>;
sendText(peerId: string, text: string): Promise<void>;
};
const MAX_TEXT_LENGTH = 4096;
export function createTelegramAdapter(
config: Config,
logger: Logger,
onMessage: MessageHandler,
): TelegramAdapter {
if (!config.telegramToken) {
throw new Error("TELEGRAM_BOT_TOKEN is required for Telegram adapter");
}
const bot = new Bot(config.telegramToken);
bot.catch((err: BotError<Context>) => {
logger.error({ error: err.error }, "telegram bot error");
});
bot.on("message", async (ctx: Context) => {
const msg = ctx.message;
if (!msg?.chat) return;
const chatType = msg.chat.type as string;
const isGroup = chatType === "group" || chatType === "supergroup" || chatType === "channel";
if (isGroup && !config.groupsEnabled) {
return;
}
const text = msg.text ?? msg.caption ?? "";
if (!text.trim()) return;
await onMessage({
channel: "telegram",
peerId: String(msg.chat.id),
text,
raw: msg,
});
});
return {
name: "telegram",
maxTextLength: MAX_TEXT_LENGTH,
async start() {
await bot.start();
logger.info("telegram adapter started");
},
async stop() {
bot.stop();
logger.info("telegram adapter stopped");
},
async sendText(peerId: string, text: string) {
await bot.api.sendMessage(Number(peerId), text);
},
};
}

View File

@@ -0,0 +1,38 @@
export function chunkText(input: string, limit: number): string[] {
if (input.length <= limit) return [input];
const chunks: string[] = [];
let current = "";
for (const line of input.split(/\n/)) {
if ((current + line).length + 1 > limit) {
if (current) chunks.push(current.trimEnd());
current = "";
}
if (line.length > limit) {
for (let i = 0; i < line.length; i += limit) {
const slice = line.slice(i, i + limit);
if (slice.length) chunks.push(slice);
}
continue;
}
current += current ? `\n${line}` : line;
}
if (current.trim().length) chunks.push(current.trimEnd());
return chunks.length ? chunks : [input];
}
export function truncateText(text: string, limit: number): string {
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1))}`;
}
export function formatInputSummary(input: Record<string, unknown>): string {
const entries = Object.entries(input);
if (!entries.length) return "";
try {
return JSON.stringify(input);
} catch {
return entries.map(([key, value]) => `${key}=${String(value)}`).join(", ");
}
}

View File

@@ -0,0 +1,204 @@
import fs from "node:fs";
import path from "node:path";
import {
DisconnectReason,
fetchLatestBaileysVersion,
isJidGroup,
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
type WAMessage,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import type { Logger } from "pino";
import type { Config } from "./config.js";
export type InboundMessage = {
channel: "whatsapp";
peerId: string;
text: string;
raw: unknown;
};
export type MessageHandler = (message: InboundMessage) => Promise<void> | void;
export type WhatsAppAdapter = {
name: "whatsapp";
maxTextLength: number;
start(): Promise<void>;
stop(): Promise<void>;
sendText(peerId: string, text: string): Promise<void>;
};
const MAX_TEXT_LENGTH = 3800;
function extractText(message: WAMessage): string {
const content = message.message;
if (!content) return "";
return (
content.conversation ||
content.extendedTextMessage?.text ||
content.imageMessage?.caption ||
content.videoMessage?.caption ||
content.documentMessage?.caption ||
""
);
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
export function createWhatsAppAdapter(
config: Config,
logger: Logger,
onMessage: MessageHandler,
opts: { printQr?: boolean } = {},
): WhatsAppAdapter {
let socket: ReturnType<typeof makeWASocket> | null = null;
let stopped = false;
const log = logger.child({ channel: "whatsapp" });
const authDir = path.resolve(config.whatsappAuthDir);
ensureDir(authDir);
async function connect() {
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, log),
},
version,
logger: log,
printQRInTerminal: false,
syncFullHistory: false,
markOnlineOnConnect: false,
browser: ["owpenbot", "cli", "0.1.0"],
});
sock.ev.on("creds.update", saveCreds);
sock.ev.on("connection.update", (update: { connection?: string; lastDisconnect?: unknown; qr?: string }) => {
if (update.qr && opts.printQr) {
qrcode.generate(update.qr, { small: true });
log.info("scan the QR code to connect WhatsApp");
}
if (update.connection === "open") {
log.info("whatsapp connected");
}
if (update.connection === "close") {
const lastDisconnect = update.lastDisconnect as
| { error?: { output?: { statusCode?: number } } }
| undefined;
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
if (shouldReconnect && !stopped) {
log.warn("whatsapp connection closed, reconnecting");
void connect();
} else if (!shouldReconnect) {
log.warn("whatsapp logged out, run 'owpenbot whatsapp login'");
}
}
});
sock.ev.on("messages.upsert", async ({ messages }: { messages: WAMessage[] }) => {
for (const msg of messages) {
if (!msg.message || msg.key.fromMe) continue;
const peerId = msg.key.remoteJid;
if (!peerId) continue;
if (isJidGroup(peerId) && !config.groupsEnabled) {
continue;
}
const text = extractText(msg);
if (!text.trim()) continue;
await onMessage({
channel: "whatsapp",
peerId,
text,
raw: msg,
});
}
});
socket = sock;
}
return {
name: "whatsapp",
maxTextLength: MAX_TEXT_LENGTH,
async start() {
await connect();
},
async stop() {
stopped = true;
if (socket) {
socket.end(undefined);
socket = null;
}
},
async sendText(peerId: string, text: string) {
if (!socket) throw new Error("WhatsApp socket not initialized");
await socket.sendMessage(peerId, { text });
},
};
}
export async function loginWhatsApp(config: Config, logger: Logger) {
const authDir = path.resolve(config.whatsappAuthDir);
ensureDir(authDir);
const log = logger.child({ channel: "whatsapp" });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
await new Promise<void>((resolve) => {
let finished = false;
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, log),
},
version,
logger: log,
printQRInTerminal: false,
syncFullHistory: false,
markOnlineOnConnect: false,
browser: ["owpenbot", "cli", "0.1.0"],
});
const finish = (reason: string) => {
if (finished) return;
finished = true;
log.info({ reason }, "whatsapp login finished");
sock.end(undefined);
resolve();
};
sock.ev.on("creds.update", async () => {
await saveCreds();
if (state.creds?.registered) {
finish("creds.registered");
}
});
sock.ev.on("connection.update", (update: { connection?: string; qr?: string }) => {
if (update.qr) {
qrcode.generate(update.qr, { small: true });
log.info("scan the QR code to connect WhatsApp");
}
if (update.connection === "open") {
finish("connection.open");
}
if (update.connection === "close" && state.creds?.registered) {
finish("connection.close.registered");
}
});
});
}

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { BridgeStore } from "../dist/db.js";
test("BridgeStore allowlist and sessions", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-"));
const dbPath = path.join(dir, "owpenbot.db");
const store = new BridgeStore(dbPath);
assert.equal(store.isAllowed("telegram", "123"), false);
store.allowPeer("telegram", "123");
assert.equal(store.isAllowed("telegram", "123"), true);
store.upsertSession("telegram", "123", "session-1");
const row = store.getSession("telegram", "123");
assert.equal(row?.session_id, "session-1");
store.close();
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}

1494
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff