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.
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
18
packages/desktop/src-tauri/Cargo.lock
generated
18
packages/desktop/src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
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