mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -20,6 +20,10 @@ It’s a native desktop app that runs **OpenCode** under the hood, but presents
|
||||
|
||||
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
|
||||
Download the dmg here https://github.com/different-ai/openwork/releases (or install from source below)
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
"@whiskeysockets/baileys",
|
||||
"better-sqlite3",
|
||||
"esbuild",
|
||||
"protobufjs"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
|
||||
18
packages/desktop/src-tauri/Cargo.lock
generated
18
packages/desktop/src-tauri/Cargo.lock
generated
@@ -397,9 +397,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.53"
|
||||
version = "1.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932"
|
||||
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -2381,7 +2381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openwork"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"json5",
|
||||
"serde",
|
||||
@@ -2827,9 +2827,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.105"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -2900,9 +2900,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -3584,9 +3584,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
|
||||
96
packages/owpenbot/README.md
Normal file
96
packages/owpenbot/README.md
Normal 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: WhatsApp’s “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
|
||||
```
|
||||
79
packages/owpenbot/install.sh
Normal file
79
packages/owpenbot/install.sh
Normal 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
|
||||
37
packages/owpenbot/package.json
Normal file
37
packages/owpenbot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
packages/owpenbot/scripts/setup.mjs
Normal file
26
packages/owpenbot/scripts/setup.mjs
Normal 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");
|
||||
}
|
||||
51
packages/owpenbot/scripts/smoke.mjs
Normal file
51
packages/owpenbot/scripts/smoke.mjs
Normal 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,
|
||||
}),
|
||||
);
|
||||
293
packages/owpenbot/src/bridge.ts
Normal file
293
packages/owpenbot/src/bridge.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
54
packages/owpenbot/src/cli.ts
Normal file
54
packages/owpenbot/src/cli.ts
Normal 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);
|
||||
127
packages/owpenbot/src/config.ts
Normal file
127
packages/owpenbot/src/config.ts
Normal 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
122
packages/owpenbot/src/db.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
packages/owpenbot/src/events.ts
Normal file
21
packages/owpenbot/src/events.ts
Normal 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;
|
||||
}
|
||||
44
packages/owpenbot/src/health.ts
Normal file
44
packages/owpenbot/src/health.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
8
packages/owpenbot/src/logger.ts
Normal file
8
packages/owpenbot/src/logger.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import pino from "pino";
|
||||
|
||||
export function createLogger(level: string) {
|
||||
return pino({
|
||||
level,
|
||||
base: undefined,
|
||||
});
|
||||
}
|
||||
43
packages/owpenbot/src/opencode.ts
Normal file
43
packages/owpenbot/src/opencode.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
19
packages/owpenbot/src/pairing.ts
Normal file
19
packages/owpenbot/src/pairing.ts
Normal 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;
|
||||
}
|
||||
76
packages/owpenbot/src/telegram.ts
Normal file
76
packages/owpenbot/src/telegram.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
packages/owpenbot/src/text.ts
Normal file
38
packages/owpenbot/src/text.ts
Normal 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(", ");
|
||||
}
|
||||
}
|
||||
204
packages/owpenbot/src/whatsapp.ts
Normal file
204
packages/owpenbot/src/whatsapp.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
23
packages/owpenbot/test/db.test.js
Normal file
23
packages/owpenbot/test/db.test.js
Normal 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();
|
||||
});
|
||||
16
packages/owpenbot/tsconfig.json
Normal file
16
packages/owpenbot/tsconfig.json
Normal 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
1494
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user