feat: improve owpenbot WhatsApp onboarding (#231)

This commit is contained in:
ben
2026-01-23 23:23:36 -08:00
committed by GitHub
parent 24f6a5d5da
commit 680c1a764b
12 changed files with 523 additions and 72 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ packages/desktop/src-tauri/sidecars/
# Env
.env
.env.*
!.env.example
# OS
.DS_Store

View File

@@ -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)

View 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

View File

@@ -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: WhatsApps “message yourself” thread is not reliable for bot testing.
@@ -60,9 +72,9 @@ Note: WhatsApps “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

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
});
}
});

View File

@@ -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();
});