mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: improve owpenbot WhatsApp onboarding (#231)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ packages/desktop/src-tauri/sidecars/
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -24,7 +24,7 @@ The goal: make “agentic work” feel like a product, not a terminal.
|
||||
|
||||
- **Owpenbot (WhatsApp bot)**: a lightweight WhatsApp bridge for a running OpenCode server. Install with:
|
||||
- `curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash`
|
||||
- then run `owpenbot`
|
||||
- run `owpenbot setup`, then `owpenbot whatsapp login`, then `owpenbot start`
|
||||
- full setup: [packages/owpenbot/README.md](./packages/owpenbot/README.md)
|
||||
|
||||
|
||||
|
||||
27
packages/owpenbot/.env.example
Normal file
27
packages/owpenbot/.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
OPENCODE_URL=http://127.0.0.1:4096
|
||||
OPENCODE_DIRECTORY=
|
||||
OPENCODE_SERVER_USERNAME=
|
||||
OPENCODE_SERVER_PASSWORD=
|
||||
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_ENABLED=true
|
||||
|
||||
OWPENBOT_DATA_DIR=~/.owpenbot
|
||||
OWPENBOT_DB_PATH=~/.owpenbot/owpenbot.db
|
||||
OWPENBOT_CONFIG_PATH=~/.owpenbot/owpenbot.json
|
||||
OWPENBOT_HEALTH_PORT=3005
|
||||
|
||||
WHATSAPP_ACCOUNT_ID=default
|
||||
WHATSAPP_AUTH_DIR=
|
||||
WHATSAPP_ENABLED=true
|
||||
WHATSAPP_DM_POLICY=
|
||||
WHATSAPP_SELF_CHAT=false
|
||||
|
||||
ALLOW_FROM=
|
||||
ALLOW_FROM_TELEGRAM=
|
||||
ALLOW_FROM_WHATSAPP=
|
||||
TOOL_UPDATES_ENABLED=false
|
||||
GROUPS_ENABLED=false
|
||||
TOOL_OUTPUT_LIMIT=1200
|
||||
PERMISSION_MODE=allow
|
||||
LOG_LEVEL=info
|
||||
@@ -10,7 +10,7 @@ One-command install (recommended):
|
||||
curl -fsSL https://raw.githubusercontent.com/different-ai/openwork/dev/packages/owpenbot/install.sh | bash
|
||||
```
|
||||
|
||||
Then follow the printed next steps (edit `.env`, run `owpenbot`).
|
||||
Then follow the printed next steps (run `owpenbot setup`, link WhatsApp, start the bridge).
|
||||
|
||||
1) One-command setup (installs deps, builds, creates `.env` if missing):
|
||||
|
||||
@@ -18,7 +18,7 @@ Then follow the printed next steps (edit `.env`, run `owpenbot`).
|
||||
pnpm -C packages/owpenbot setup
|
||||
```
|
||||
|
||||
2) Fill in `packages/owpenbot/.env` (see `.env.example`).
|
||||
2) (Optional) Fill in `packages/owpenbot/.env` (see `.env.example`).
|
||||
|
||||
Required:
|
||||
- `OPENCODE_URL`
|
||||
@@ -29,19 +29,31 @@ Recommended:
|
||||
- `OPENCODE_SERVER_USERNAME`
|
||||
- `OPENCODE_SERVER_PASSWORD`
|
||||
|
||||
3) Run the bridge:
|
||||
3) Run setup (writes `~/.owpenbot/owpenbot.json`):
|
||||
|
||||
```bash
|
||||
owpenbot
|
||||
owpenbot setup
|
||||
```
|
||||
|
||||
Owpenbot prints a QR code if WhatsApp is not paired and keeps the session alive once connected.
|
||||
4) Link WhatsApp (QR):
|
||||
|
||||
5) Pair a user with the bot:
|
||||
```bash
|
||||
owpenbot whatsapp login
|
||||
```
|
||||
|
||||
- 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.
|
||||
5) Start the bridge:
|
||||
|
||||
```bash
|
||||
owpenbot start
|
||||
```
|
||||
|
||||
Owpenbot keeps the WhatsApp session alive once connected.
|
||||
|
||||
6) Pair a user with the bot (only if DM policy is pairing):
|
||||
|
||||
- Run `owpenbot pairing list` to view pending codes.
|
||||
- Approve a code: `owpenbot pairing approve <code>`.
|
||||
- The user can then message again to receive OpenCode replies.
|
||||
|
||||
## Usage Flows
|
||||
|
||||
@@ -49,9 +61,9 @@ Owpenbot prints a QR code if WhatsApp is not paired and keeps the session alive
|
||||
|
||||
Use your own WhatsApp account as the bot and test from a second number you control.
|
||||
|
||||
1) Pair WhatsApp using your personal number (just run `owpenbot` to show the QR).
|
||||
2) Send the pairing code from a second number (SIM/eSIM or another phone).
|
||||
3) Chat from that second number to receive OpenCode replies.
|
||||
1) Run `owpenbot setup` and choose “personal number.”
|
||||
2) Run `owpenbot whatsapp login` to scan the QR.
|
||||
3) Message yourself or from a second number; your number is already allowlisted.
|
||||
|
||||
Note: WhatsApp’s “message yourself” thread is not reliable for bot testing.
|
||||
|
||||
@@ -60,9 +72,9 @@ Note: WhatsApp’s “message yourself” thread is not reliable for bot testing
|
||||
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 by running `owpenbot` and scanning the QR.
|
||||
3) Share the pairing code with the person who should use the bot.
|
||||
4) Optionally pre-allowlist specific numbers with `ALLOW_FROM_WHATSAPP=`.
|
||||
2) Run `owpenbot setup` and choose “dedicated number.”
|
||||
3) Run `owpenbot whatsapp login` to scan the QR.
|
||||
4) If DM policy is pairing, approve codes with `owpenbot pairing approve <code>`.
|
||||
|
||||
## Telegram (Untested)
|
||||
|
||||
@@ -73,14 +85,19 @@ Telegram support is wired but not E2E tested yet. To try it:
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
owpenbot
|
||||
pnpm -C packages/owpenbot pairing-code
|
||||
owpenbot setup
|
||||
owpenbot whatsapp login
|
||||
owpenbot start
|
||||
owpenbot pairing list
|
||||
owpenbot pairing approve <code>
|
||||
owpenbot status
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- SQLite at `~/.owpenbot/owpenbot.db` unless overridden.
|
||||
- Allowlist is enforced by default; a pairing code is generated if not provided.
|
||||
- Config stored at `~/.owpenbot/owpenbot.json` (created by `owpenbot setup`).
|
||||
- DM policy defaults to `pairing` unless changed in setup.
|
||||
- Group chats are disabled unless `GROUPS_ENABLED=true`.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -133,8 +133,10 @@ cat <<EOF
|
||||
Owpenbot installed.
|
||||
|
||||
Next steps:
|
||||
1) Edit $ENV_PATH
|
||||
2) Run owpenbot: owpenbot
|
||||
1) Edit $ENV_PATH (optional)
|
||||
2) Run setup: owpenbot setup
|
||||
3) Link WhatsApp: owpenbot whatsapp login
|
||||
4) Start bridge: owpenbot start
|
||||
|
||||
Owpenbot will print a QR code if WhatsApp is not paired.
|
||||
Owpenbot will print a QR code during login and keep the session alive.
|
||||
EOF
|
||||
|
||||
@@ -3,11 +3,11 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { Config, ChannelName } from "./config.js";
|
||||
import { normalizeWhatsAppId } 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";
|
||||
@@ -25,6 +25,7 @@ type InboundMessage = {
|
||||
peerId: string;
|
||||
text: string;
|
||||
raw: unknown;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
type RunState = {
|
||||
@@ -52,10 +53,11 @@ 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");
|
||||
store.seedAllowlist(
|
||||
"whatsapp",
|
||||
[...config.whatsappAllowFrom].filter((entry) => entry !== "*"),
|
||||
);
|
||||
store.prunePairingRequests();
|
||||
|
||||
const adapters = new Map<ChannelName, Adapter>();
|
||||
if (config.telegramEnabled && config.telegramToken) {
|
||||
@@ -182,32 +184,57 @@ export async function startBridge(config: Config, logger: Logger) {
|
||||
{ channel: inbound.channel, peerId: inbound.peerId, length: inbound.text.length },
|
||||
"received message",
|
||||
);
|
||||
const peerKey = inbound.channel === "whatsapp" ? normalizeWhatsAppId(inbound.peerId) : inbound.peerId;
|
||||
if (inbound.channel === "whatsapp") {
|
||||
if (config.whatsappDmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
const allowAll = config.whatsappDmPolicy === "open" || config.whatsappAllowFrom.has("*");
|
||||
const isSelf = Boolean(inbound.fromMe && config.whatsappSelfChatMode);
|
||||
const allowed = allowAll || isSelf || store.isAllowed("whatsapp", peerKey);
|
||||
if (!allowed) {
|
||||
if (config.whatsappDmPolicy === "allowlist") {
|
||||
await sendText(
|
||||
inbound.channel,
|
||||
inbound.peerId,
|
||||
"Access denied. Ask the owner to allowlist your number.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
inbound = { ...inbound, text: remaining };
|
||||
} else {
|
||||
|
||||
store.prunePairingRequests();
|
||||
const active = store.getPairingRequest("whatsapp", peerKey);
|
||||
const pending = store.listPairingRequests("whatsapp");
|
||||
if (!active && pending.length >= 3) {
|
||||
await sendText(
|
||||
inbound.channel,
|
||||
inbound.peerId,
|
||||
"Pairing queue full. Ask the owner to approve pending requests.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = active?.code ?? String(Math.floor(100000 + Math.random() * 900000));
|
||||
if (!active) {
|
||||
store.createPairingRequest("whatsapp", peerKey, code, 60 * 60_000);
|
||||
}
|
||||
await sendText(
|
||||
inbound.channel,
|
||||
inbound.peerId,
|
||||
`Pairing required. Reply with code: ${pairingCode}`,
|
||||
`Pairing required. Ask the owner to approve code: ${code}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (config.allowlist[inbound.channel].size > 0) {
|
||||
if (!store.isAllowed(inbound.channel, peerKey)) {
|
||||
await sendText(inbound.channel, inbound.peerId, "Access denied.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const session = store.getSession(inbound.channel, inbound.peerId);
|
||||
const sessionID = session?.session_id ?? (await createSession(inbound));
|
||||
const session = store.getSession(inbound.channel, peerKey);
|
||||
const sessionID = session?.session_id ?? (await createSession({ ...inbound, peerId: peerKey }));
|
||||
|
||||
enqueue(sessionID, async () => {
|
||||
const runState: RunState = {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { Command } from "commander";
|
||||
|
||||
import { startBridge } from "./bridge.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
normalizeWhatsAppId,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
type DmPolicy,
|
||||
type OwpenbotConfigFile,
|
||||
} from "./config.js";
|
||||
import { BridgeStore } from "./db.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { resolvePairingCode } from "./pairing.js";
|
||||
import { loginWhatsApp, unpairWhatsApp } from "./whatsapp.js";
|
||||
|
||||
const program = new Command();
|
||||
@@ -25,7 +34,7 @@ const runStart = async (pathOverride?: string) => {
|
||||
process.env.OPENCODE_DIRECTORY = config.opencodeDirectory;
|
||||
}
|
||||
const bridge = await startBridge(config, logger);
|
||||
logger.info("Commands: owpenbot qr, owpenbot unpair, owpenbot pairing-code");
|
||||
logger.info("Commands: owpenbot whatsapp login, owpenbot pairing list, owpenbot status");
|
||||
|
||||
const shutdown = async () => {
|
||||
logger.info("shutting down");
|
||||
@@ -44,14 +53,114 @@ program
|
||||
|
||||
program.action((pathArg: string | undefined) => runStart(pathArg));
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Create or update owpenbot.json for WhatsApp")
|
||||
.option("--non-interactive", "Write defaults without prompts", false)
|
||||
.action(async (opts) => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const { config: existing } = readConfigFile(config.configPath);
|
||||
const next: OwpenbotConfigFile = existing ?? { version: 1 };
|
||||
|
||||
if (opts.nonInteractive) {
|
||||
next.version = 1;
|
||||
next.channels = next.channels ?? {};
|
||||
next.channels.whatsapp = {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
selfChatMode: false,
|
||||
accounts: {
|
||||
[config.whatsappAccountId]: {
|
||||
authDir: config.whatsappAuthDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeConfigFile(config.configPath, next);
|
||||
console.log(`Wrote ${config.configPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const phoneMode = await rl.question(
|
||||
"WhatsApp setup: (1) Personal number (2) Dedicated number [1]: ",
|
||||
);
|
||||
const mode = phoneMode.trim() === "2" ? "dedicated" : "personal";
|
||||
|
||||
let dmPolicy: DmPolicy = "pairing";
|
||||
let allowFrom: string[] = [];
|
||||
let selfChatMode = false;
|
||||
|
||||
if (mode === "personal") {
|
||||
let normalized = "";
|
||||
while (!normalized) {
|
||||
const number = await rl.question("Your WhatsApp number (E.164, e.g. +15551234567): ");
|
||||
const candidate = normalizeWhatsAppId(number);
|
||||
if (!/^\+\d+$/.test(candidate)) {
|
||||
console.log("Invalid number. Try again.");
|
||||
continue;
|
||||
}
|
||||
normalized = candidate;
|
||||
}
|
||||
allowFrom = [normalized];
|
||||
dmPolicy = "allowlist";
|
||||
selfChatMode = true;
|
||||
} else {
|
||||
const policyInput = await rl.question(
|
||||
"DM policy: (1) Pairing (2) Allowlist (3) Open (4) Disabled [1]: ",
|
||||
);
|
||||
const policyChoice = policyInput.trim();
|
||||
if (policyChoice === "2") dmPolicy = "allowlist";
|
||||
else if (policyChoice === "3") dmPolicy = "open";
|
||||
else if (policyChoice === "4") dmPolicy = "disabled";
|
||||
else dmPolicy = "pairing";
|
||||
|
||||
const listInput = await rl.question(
|
||||
"Allowlist numbers (comma-separated, optional): ",
|
||||
);
|
||||
if (listInput.trim()) {
|
||||
allowFrom = listInput
|
||||
.split(",")
|
||||
.map((item) => normalizeWhatsAppId(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (dmPolicy === "open") {
|
||||
allowFrom = allowFrom.length ? allowFrom : ["*"];
|
||||
}
|
||||
}
|
||||
|
||||
rl.close();
|
||||
|
||||
next.version = 1;
|
||||
next.channels = next.channels ?? {};
|
||||
next.channels.whatsapp = {
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
selfChatMode,
|
||||
accounts: {
|
||||
[config.whatsappAccountId]: {
|
||||
authDir: config.whatsappAuthDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeConfigFile(config.configPath, next);
|
||||
console.log(`Wrote ${config.configPath}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("pairing-code")
|
||||
.description("Print the current pairing code")
|
||||
.description("List pending pairing codes")
|
||||
.action(() => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const store = new BridgeStore(config.dbPath);
|
||||
const code = resolvePairingCode(store, config.pairingCode);
|
||||
console.log(code);
|
||||
store.prunePairingRequests();
|
||||
const requests = store.listPairingRequests("whatsapp");
|
||||
if (!requests.length) {
|
||||
console.log("No pending pairing requests.");
|
||||
} else {
|
||||
for (const request of requests) {
|
||||
console.log(`${request.code} ${request.peer_id}`);
|
||||
}
|
||||
}
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -66,6 +175,15 @@ whatsapp
|
||||
await loginWhatsApp(config, logger);
|
||||
});
|
||||
|
||||
whatsapp
|
||||
.command("logout")
|
||||
.description("Logout of WhatsApp and clear auth state")
|
||||
.action(() => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const logger = createLogger(config.logLevel);
|
||||
unpairWhatsApp(config, logger);
|
||||
});
|
||||
|
||||
program
|
||||
.command("qr")
|
||||
.description("Print a WhatsApp QR code to pair")
|
||||
@@ -84,4 +202,81 @@ program
|
||||
unpairWhatsApp(config, logger);
|
||||
});
|
||||
|
||||
const pairing = program.command("pairing").description("Pairing requests");
|
||||
|
||||
pairing
|
||||
.command("list")
|
||||
.description("List pending pairing requests")
|
||||
.action(() => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const store = new BridgeStore(config.dbPath);
|
||||
store.prunePairingRequests();
|
||||
const requests = store.listPairingRequests("whatsapp");
|
||||
if (!requests.length) {
|
||||
console.log("No pending pairing requests.");
|
||||
} else {
|
||||
for (const request of requests) {
|
||||
console.log(`${request.code} ${request.peer_id}`);
|
||||
}
|
||||
}
|
||||
store.close();
|
||||
});
|
||||
|
||||
pairing
|
||||
.command("approve")
|
||||
.argument("<code>")
|
||||
.description("Approve a pairing request")
|
||||
.action((code: string) => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const store = new BridgeStore(config.dbPath);
|
||||
const request = store.approvePairingRequest("whatsapp", code.trim());
|
||||
if (!request) {
|
||||
console.log("Pairing code not found or expired.");
|
||||
store.close();
|
||||
return;
|
||||
}
|
||||
store.allowPeer("whatsapp", request.peer_id);
|
||||
store.close();
|
||||
console.log(`Approved ${request.peer_id}`);
|
||||
});
|
||||
|
||||
pairing
|
||||
.command("deny")
|
||||
.argument("<code>")
|
||||
.description("Deny a pairing request")
|
||||
.action((code: string) => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const store = new BridgeStore(config.dbPath);
|
||||
const ok = store.denyPairingRequest("whatsapp", code.trim());
|
||||
store.close();
|
||||
console.log(ok ? "Removed pairing request." : "Pairing code not found.");
|
||||
});
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show WhatsApp and OpenCode status")
|
||||
.action(async () => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const authPath = `${config.whatsappAuthDir}/creds.json`;
|
||||
const linked = fs.existsSync(authPath);
|
||||
console.log(`Config: ${config.configPath}`);
|
||||
console.log(`WhatsApp linked: ${linked ? "yes" : "no"}`);
|
||||
console.log(`Auth dir: ${config.whatsappAuthDir}`);
|
||||
console.log(`OpenCode URL: ${config.opencodeUrl}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Diagnose common issues")
|
||||
.action(async () => {
|
||||
const config = loadConfig(process.env, { requireOpencode: false });
|
||||
const authPath = `${config.whatsappAuthDir}/creds.json`;
|
||||
if (!fs.existsSync(authPath)) {
|
||||
console.log("WhatsApp not linked. Run: owpenbot whatsapp login");
|
||||
} else {
|
||||
console.log("WhatsApp linked.");
|
||||
}
|
||||
console.log("If replies fail, ensure OpenCode server is running at OPENCODE_URL.");
|
||||
});
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -11,7 +12,29 @@ dotenv.config();
|
||||
|
||||
export type ChannelName = "telegram" | "whatsapp";
|
||||
|
||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
|
||||
export type OwpenbotConfigFile = {
|
||||
version: number;
|
||||
channels?: {
|
||||
whatsapp?: {
|
||||
dmPolicy?: DmPolicy;
|
||||
allowFrom?: string[];
|
||||
selfChatMode?: boolean;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{
|
||||
authDir?: string;
|
||||
sendReadReceipts?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
configPath: string;
|
||||
configFile: OwpenbotConfigFile;
|
||||
opencodeUrl: string;
|
||||
opencodeDirectory: string;
|
||||
opencodeUsername?: string;
|
||||
@@ -19,11 +42,14 @@ export type Config = {
|
||||
telegramToken?: string;
|
||||
telegramEnabled: boolean;
|
||||
whatsappAuthDir: string;
|
||||
whatsappAccountId: string;
|
||||
whatsappDmPolicy: DmPolicy;
|
||||
whatsappAllowFrom: Set<string>;
|
||||
whatsappSelfChatMode: boolean;
|
||||
whatsappEnabled: boolean;
|
||||
dataDir: string;
|
||||
dbPath: string;
|
||||
allowlist: Record<ChannelName, Set<string>>;
|
||||
pairingCode?: string;
|
||||
toolUpdatesEnabled: boolean;
|
||||
groupsEnabled: boolean;
|
||||
permissionMode: "allow" | "deny";
|
||||
@@ -58,6 +84,61 @@ function expandHome(value: string): string {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
|
||||
export function normalizeWhatsAppId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.endsWith("@g.us")) return trimmed;
|
||||
const base = trimmed.replace(/@s\.whatsapp\.net$/i, "");
|
||||
if (base.startsWith("+")) return base;
|
||||
if (/^\d+$/.test(base)) return `+${base}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function normalizeWhatsAppAllowFrom(list: string[]): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const entry of list) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed === "*") {
|
||||
set.add("*");
|
||||
continue;
|
||||
}
|
||||
set.add(normalizeWhatsAppId(trimmed));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function normalizeDmPolicy(value: unknown): DmPolicy {
|
||||
if (value === "allowlist" || value === "open" || value === "disabled" || value === "pairing") {
|
||||
return value;
|
||||
}
|
||||
return "pairing";
|
||||
}
|
||||
|
||||
function resolveConfigPath(dataDir: string, env: EnvLike): string {
|
||||
const override = env.OWPENBOT_CONFIG_PATH?.trim();
|
||||
if (override) return expandHome(override);
|
||||
return path.join(dataDir, "owpenbot.json");
|
||||
}
|
||||
|
||||
export function readConfigFile(configPath: string): { exists: boolean; config: OwpenbotConfigFile } {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as OwpenbotConfigFile;
|
||||
return { exists: true, config: parsed };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { exists: false, config: { version: 1 } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeConfigFile(configPath: string, config: OwpenbotConfigFile) {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
function parseAllowlist(env: EnvLike): Record<ChannelName, Set<string>> {
|
||||
const allowlist: Record<ChannelName, Set<string>> = {
|
||||
telegram: new Set<string>(),
|
||||
@@ -103,12 +184,33 @@ export function loadConfig(
|
||||
|
||||
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 configPath = resolveConfigPath(dataDir, env);
|
||||
const { config: configFile } = readConfigFile(configPath);
|
||||
const whatsappFile = configFile.channels?.whatsapp ?? {};
|
||||
const whatsappAccountId = env.WHATSAPP_ACCOUNT_ID?.trim() || "default";
|
||||
const accountAuthDir = whatsappFile.accounts?.[whatsappAccountId]?.authDir;
|
||||
const whatsappAuthDir = expandHome(
|
||||
env.WHATSAPP_AUTH_DIR ??
|
||||
accountAuthDir ??
|
||||
path.join(dataDir, "credentials", "whatsapp", whatsappAccountId),
|
||||
);
|
||||
const dmPolicy = normalizeDmPolicy(
|
||||
env.WHATSAPP_DM_POLICY?.trim().toLowerCase() ?? whatsappFile.dmPolicy,
|
||||
);
|
||||
const selfChatMode = parseBoolean(env.WHATSAPP_SELF_CHAT, whatsappFile.selfChatMode ?? false);
|
||||
const envAllowlist = parseAllowlist(env);
|
||||
const fileAllowFrom = normalizeWhatsAppAllowFrom(whatsappFile.allowFrom ?? []);
|
||||
const envAllowFrom = normalizeWhatsAppAllowFrom(
|
||||
envAllowlist.whatsapp.size ? [...envAllowlist.whatsapp] : [],
|
||||
);
|
||||
const whatsappAllowFrom = new Set<string>([...fileAllowFrom, ...envAllowFrom]);
|
||||
|
||||
const toolOutputLimit = parseInteger(env.TOOL_OUTPUT_LIMIT) ?? 1200;
|
||||
const permissionMode = env.PERMISSION_MODE?.toLowerCase() === "deny" ? "deny" : "allow";
|
||||
|
||||
return {
|
||||
configPath,
|
||||
configFile,
|
||||
opencodeUrl: env.OPENCODE_URL?.trim() ?? "http://127.0.0.1:4096",
|
||||
opencodeDirectory: resolvedDirectory,
|
||||
opencodeUsername: env.OPENCODE_SERVER_USERNAME?.trim() || undefined,
|
||||
@@ -116,11 +218,14 @@ export function loadConfig(
|
||||
telegramToken: env.TELEGRAM_BOT_TOKEN?.trim() || undefined,
|
||||
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, Boolean(env.TELEGRAM_BOT_TOKEN?.trim())),
|
||||
whatsappAuthDir,
|
||||
whatsappAccountId,
|
||||
whatsappDmPolicy: dmPolicy,
|
||||
whatsappAllowFrom,
|
||||
whatsappSelfChatMode: selfChatMode,
|
||||
whatsappEnabled: parseBoolean(env.WHATSAPP_ENABLED, true),
|
||||
dataDir,
|
||||
dbPath,
|
||||
allowlist: parseAllowlist(env),
|
||||
pairingCode: env.PAIRING_CODE?.trim() || undefined,
|
||||
allowlist: envAllowlist,
|
||||
toolUpdatesEnabled: parseBoolean(env.TOOL_UPDATES_ENABLED, false),
|
||||
groupsEnabled: parseBoolean(env.GROUPS_ENABLED, false),
|
||||
permissionMode,
|
||||
|
||||
@@ -19,6 +19,14 @@ type AllowlistRow = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type PairingRow = {
|
||||
channel: ChannelName;
|
||||
peer_id: string;
|
||||
code: string;
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
export class BridgeStore {
|
||||
private db: Database.Database;
|
||||
|
||||
@@ -45,6 +53,14 @@ export class BridgeStore {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pairing_requests (
|
||||
channel TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (channel, peer_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -103,6 +119,63 @@ export class BridgeStore {
|
||||
transaction(peers);
|
||||
}
|
||||
|
||||
listPairingRequests(channel?: ChannelName): PairingRow[] {
|
||||
const now = Date.now();
|
||||
const stmt = channel
|
||||
? this.db.prepare(
|
||||
"SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND expires_at > ? ORDER BY created_at ASC",
|
||||
)
|
||||
: this.db.prepare(
|
||||
"SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE expires_at > ? ORDER BY created_at ASC",
|
||||
);
|
||||
const rows = (channel ? stmt.all(channel, now) : stmt.all(now)) as PairingRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
getPairingRequest(channel: ChannelName, peerId: string): PairingRow | null {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(
|
||||
"SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND peer_id = ? AND expires_at > ?",
|
||||
);
|
||||
const row = stmt.get(channel, peerId, now) as PairingRow | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
createPairingRequest(channel: ChannelName, peerId: string, code: string, ttlMs: number) {
|
||||
const now = Date.now();
|
||||
const expiresAt = now + ttlMs;
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO pairing_requests (channel, peer_id, code, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(channel, peer_id) DO UPDATE SET code = excluded.code, created_at = excluded.created_at, expires_at = excluded.expires_at`,
|
||||
);
|
||||
stmt.run(channel, peerId, code, now, expiresAt);
|
||||
}
|
||||
|
||||
approvePairingRequest(channel: ChannelName, code: string): PairingRow | null {
|
||||
const now = Date.now();
|
||||
const select = this.db.prepare(
|
||||
"SELECT channel, peer_id, code, created_at, expires_at FROM pairing_requests WHERE channel = ? AND code = ? AND expires_at > ?",
|
||||
);
|
||||
const row = select.get(channel, code, now) as PairingRow | undefined;
|
||||
if (!row) return null;
|
||||
const del = this.db.prepare("DELETE FROM pairing_requests WHERE channel = ? AND peer_id = ?");
|
||||
del.run(channel, row.peer_id);
|
||||
return row;
|
||||
}
|
||||
|
||||
denyPairingRequest(channel: ChannelName, code: string): boolean {
|
||||
const stmt = this.db.prepare("DELETE FROM pairing_requests WHERE channel = ? AND code = ?");
|
||||
const result = stmt.run(channel, code);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
prunePairingRequests() {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare("DELETE FROM pairing_requests WHERE expires_at <= ?");
|
||||
stmt.run(now);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type InboundMessage = {
|
||||
peerId: string;
|
||||
text: string;
|
||||
raw: unknown;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
export type MessageHandler = (message: InboundMessage) => Promise<void> | void;
|
||||
@@ -109,7 +110,9 @@ export function createWhatsAppAdapter(
|
||||
|
||||
sock.ev.on("messages.upsert", async ({ messages }: { messages: WAMessage[] }) => {
|
||||
for (const msg of messages) {
|
||||
if (!msg.message || msg.key.fromMe) continue;
|
||||
if (!msg.message) continue;
|
||||
const fromMe = Boolean(msg.key.fromMe);
|
||||
if (fromMe && !config.whatsappSelfChatMode) continue;
|
||||
const peerId = msg.key.remoteJid;
|
||||
if (!peerId) continue;
|
||||
if (isJidGroup(peerId) && !config.groupsEnabled) {
|
||||
@@ -123,6 +126,7 @@ export function createWhatsAppAdapter(
|
||||
peerId,
|
||||
text,
|
||||
raw: msg,
|
||||
fromMe,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,3 +21,22 @@ test("BridgeStore allowlist and sessions", () => {
|
||||
|
||||
store.close();
|
||||
});
|
||||
|
||||
test("BridgeStore pairing requests", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-"));
|
||||
const dbPath = path.join(dir, "owpenbot.db");
|
||||
const store = new BridgeStore(dbPath);
|
||||
|
||||
store.createPairingRequest("whatsapp", "+15551234567", "123456", 1000);
|
||||
const list = store.listPairingRequests("whatsapp");
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].code, "123456");
|
||||
|
||||
const approved = store.approvePairingRequest("whatsapp", "123456");
|
||||
assert.equal(approved?.peer_id, "+15551234567");
|
||||
|
||||
const empty = store.listPairingRequests("whatsapp");
|
||||
assert.equal(empty.length, 0);
|
||||
|
||||
store.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user