diff --git a/.gitignore b/.gitignore index e76c0c6b..00c3f22c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ packages/desktop/src-tauri/sidecars/ # Env .env .env.* +!.env.example # OS .DS_Store diff --git a/README.md b/README.md index 2c19514f..d843e74c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/packages/owpenbot/.env.example b/packages/owpenbot/.env.example new file mode 100644 index 00000000..e6439c03 --- /dev/null +++ b/packages/owpenbot/.env.example @@ -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 diff --git a/packages/owpenbot/README.md b/packages/owpenbot/README.md index 079458c2..abe2cfb7 100644 --- a/packages/owpenbot/README.md +++ b/packages/owpenbot/README.md @@ -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 `. +- 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 `. ## 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 +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 diff --git a/packages/owpenbot/install.sh b/packages/owpenbot/install.sh index a96b9c0a..9161bc17 100644 --- a/packages/owpenbot/install.sh +++ b/packages/owpenbot/install.sh @@ -133,8 +133,10 @@ cat < entry !== "*"), + ); + store.prunePairingRequests(); const adapters = new Map(); 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 = { diff --git a/packages/owpenbot/src/cli.ts b/packages/owpenbot/src/cli.ts index e597aada..939aca5c 100644 --- a/packages/owpenbot/src/cli.ts +++ b/packages/owpenbot/src/cli.ts @@ -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("") + .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("") + .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); diff --git a/packages/owpenbot/src/config.ts b/packages/owpenbot/src/config.ts index 67767ed5..b7c5f325 100644 --- a/packages/owpenbot/src/config.ts +++ b/packages/owpenbot/src/config.ts @@ -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; + whatsappSelfChatMode: boolean; whatsappEnabled: boolean; dataDir: string; dbPath: string; allowlist: Record>; - 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 { + const set = new Set(); + 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> { const allowlist: Record> = { telegram: new Set(), @@ -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([...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, diff --git a/packages/owpenbot/src/db.ts b/packages/owpenbot/src/db.ts index ddc987df..c6f85c2c 100644 --- a/packages/owpenbot/src/db.ts +++ b/packages/owpenbot/src/db.ts @@ -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; diff --git a/packages/owpenbot/src/pairing.ts b/packages/owpenbot/src/pairing.ts deleted file mode 100644 index 1123703d..00000000 --- a/packages/owpenbot/src/pairing.ts +++ /dev/null @@ -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; -} diff --git a/packages/owpenbot/src/whatsapp.ts b/packages/owpenbot/src/whatsapp.ts index 41a186fa..190181ca 100644 --- a/packages/owpenbot/src/whatsapp.ts +++ b/packages/owpenbot/src/whatsapp.ts @@ -20,6 +20,7 @@ export type InboundMessage = { peerId: string; text: string; raw: unknown; + fromMe?: boolean; }; export type MessageHandler = (message: InboundMessage) => Promise | 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, }); } }); diff --git a/packages/owpenbot/test/db.test.js b/packages/owpenbot/test/db.test.js index 6fb33895..66a57789 100644 --- a/packages/owpenbot/test/db.test.js +++ b/packages/owpenbot/test/db.test.js @@ -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(); +});