From 091a35b15e6ceed88b7d90a69aaf3e7ee7956771 Mon Sep 17 00:00:00 2001 From: Mingyang Xu Date: Sat, 14 Mar 2026 10:17:28 -0400 Subject: [PATCH] Add Windows-friendly local web chat and upload flow --- README.zh-CN.md | 10 + config-examples/.env.local-web.example | 22 + docs/WINDOWS.zh-CN.md | 159 ++++++ src/channels/local-web.ts | 700 +++++++++++++++++++++++++ src/channels/whatsapp.test.ts | 32 +- src/channels/whatsapp.ts | 8 +- src/cli.ts | 12 +- src/config.ts | 17 +- src/container-runner.ts | 12 +- src/db.ts | 18 + src/env.ts | 33 ++ src/formatting.test.ts | 20 +- src/index.ts | 123 ++++- src/platform.ts | 27 + src/whatsapp-auth.ts | 5 +- 15 files changed, 1134 insertions(+), 64 deletions(-) create mode 100644 config-examples/.env.local-web.example create mode 100644 docs/WINDOWS.zh-CN.md create mode 100644 src/channels/local-web.ts create mode 100644 src/env.ts create mode 100644 src/platform.ts diff --git a/README.zh-CN.md b/README.zh-CN.md index 927249b..270bae8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,6 +41,10 @@ BioClaw 将常见的生物信息学任务带到聊天界面中。研究者可以 ## 快速开始 +> 说明:当前仓库中已经实现的消息通道是 WhatsApp。文档中的 QQ / 飞书截图展示的是扩展方向,不代表仓库里已经内置了可直接运行的 QQ / 飞书通道。 + +> 现在也支持一个更适合 Windows 用户的本地网页聊天入口。若你在中国、或者暂时不想接 WhatsApp,可直接走 `HTTP webhook + 本地网页聊天`。 + ### 环境要求 - macOS 或 Linux @@ -106,6 +110,10 @@ npm run dev @Bioclaw <你的请求> ``` +如果你在 Windows 上、或者暂时不想通过 WhatsApp 使用,请先看 [docs/WINDOWS.zh-CN.md](docs/WINDOWS.zh-CN.md)。当前最稳妥的方式是 `WSL2 + Docker Desktop + npm run cli`。 + +如果你想直接在浏览器里聊天,请在 `.env` 中设置 `ENABLE_WHATSAPP=false` 和 `ENABLE_LOCAL_WEB=true`,再执行 `npm run dev`,最后打开 [http://127.0.0.1:3210](http://127.0.0.1:3210)。 + ### Second Quick Start 如果希望更“无脑”地引导安装,给 OpenClaw 发送: @@ -134,6 +142,8 @@ install https://github.com/Runchuan-BU/BioClaw 更多任务示例见 [ExampleTask/ExampleTask.md](ExampleTask/ExampleTask.md)。 +> 注意:上面的 QQ / 飞书图片目前是产品展示示例,不是仓库内现成可启用的接入实现。 + ## 系统架构 BioClaw 基于 NanoClaw 的容器化架构,并融合 STELLA 的生物医学能力: diff --git a/config-examples/.env.local-web.example b/config-examples/.env.local-web.example new file mode 100644 index 0000000..4301e88 --- /dev/null +++ b/config-examples/.env.local-web.example @@ -0,0 +1,22 @@ +# Local web chat only. Suitable for Windows + China-friendly setup. + +# Disable WhatsApp if you only want the browser chat. +ENABLE_WHATSAPP=false +ENABLE_LOCAL_WEB=true +LOCAL_WEB_HOST=127.0.0.1 +LOCAL_WEB_PORT=3210 + +# Optional: protect POST /webhook with a shared secret header. +# LOCAL_WEB_SECRET=change-me + +# Recommended in China: use an OpenAI-compatible provider. +MODEL_PROVIDER=openai-compatible +OPENAI_COMPATIBLE_API_KEY=your_api_key +OPENAI_COMPATIBLE_BASE_URL=https://your-provider.example/v1 +OPENAI_COMPATIBLE_MODEL=your-model-name + +# Alternative: OpenRouter +# MODEL_PROVIDER=openrouter +# OPENROUTER_API_KEY=your_openrouter_key +# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +# OPENROUTER_MODEL=openai/gpt-5.4 diff --git a/docs/WINDOWS.zh-CN.md b/docs/WINDOWS.zh-CN.md new file mode 100644 index 0000000..1d17a72 --- /dev/null +++ b/docs/WINDOWS.zh-CN.md @@ -0,0 +1,159 @@ +# Windows 使用说明(最小可用方案) + +这份说明面向需要在 Windows 上尽快跑通 BioClaw 的用户,尤其是暂时不走 WhatsApp、而是先本地验证模型和生物工具链的场景。 + +## 当前现状 + +- 仓库中的现成消息通道只有 WhatsApp。 +- `README.zh-CN.md` 里提到的 QQ / 飞书(Lark)目前是展示和扩展方向,不是已提交的可直接运行实现。 +- 对 Windows,当前推荐方案是: + - Windows 11 + - WSL2 + - Docker Desktop(开启 WSL2 backend) + - 使用 CLI 模式先本地跑通 + +## 推荐操作路径 + +如果你的用户在中国,而且不方便使用 WhatsApp,最现实的顺序是: + +1. 先在 Windows 上用 CLI 模式验证 BioClaw 可运行。 +2. 确认模型提供方可用。 +3. 后续再决定是否接 QQ / 飞书 webhook 频道。 + +## 安装步骤 + +### 1. 安装基础环境 + +- 安装 Node.js 20+ +- 安装 Docker Desktop +- 在 Docker Desktop 设置中启用 WSL2 backend + +### 2. 克隆并安装依赖 + +```bash +git clone https://github.com/Runchuan-BU/BioClaw.git +cd BioClaw +npm install +``` + +### 3. 配置 `.env` + +在项目根目录创建 `.env`。最省事的方式是先复制示例: + +```bash +cp config-examples/.env.local-web.example .env +``` + +如果你在 Windows PowerShell 里操作,也可以直接新建 `.env` 文件,然后把下面内容粘进去。 + +如果你在中国,通常更适合先用兼容 OpenAI 的提供方: + +```bash +ENABLE_WHATSAPP=false +ENABLE_LOCAL_WEB=true +LOCAL_WEB_HOST=127.0.0.1 +LOCAL_WEB_PORT=3210 + +MODEL_PROVIDER=openai-compatible +OPENAI_COMPATIBLE_API_KEY=your_api_key +OPENAI_COMPATIBLE_BASE_URL=https://your-provider.example/v1 +OPENAI_COMPATIBLE_MODEL=your-model-name +``` + +这几行分别是什么意思: + +- `ENABLE_WHATSAPP=false` + - 关闭 WhatsApp 通道,避免启动时要求 WhatsApp 认证。 +- `ENABLE_LOCAL_WEB=true` + - 打开本地网页聊天入口。 +- `LOCAL_WEB_HOST=127.0.0.1` + - 只允许本机访问,更安全。 +- `LOCAL_WEB_PORT=3210` + - 本地网页聊天端口,浏览器稍后会访问 `http://127.0.0.1:3210`。 +- `MODEL_PROVIDER=openai-compatible` + - 选择兼容 OpenAI 的模型接口。 +- `OPENAI_COMPATIBLE_API_KEY` + - 你从模型服务商那里拿到的密钥。 +- `OPENAI_COMPATIBLE_BASE_URL` + - 服务商提供的 API 根地址,通常以 `/v1` 结尾。 +- `OPENAI_COMPATIBLE_MODEL` + - 具体模型名,按服务商文档填写。 + +如果你使用 OpenRouter: + +```bash +ENABLE_WHATSAPP=false +ENABLE_LOCAL_WEB=true +LOCAL_WEB_HOST=127.0.0.1 +LOCAL_WEB_PORT=3210 + +MODEL_PROVIDER=openrouter +OPENROUTER_API_KEY=your_openrouter_key +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +OPENROUTER_MODEL=openai/gpt-5.4 +``` + +如果你还想让外部系统调 webhook,可以再加: + +```bash +LOCAL_WEB_SECRET=change-me +``` + +之后外部系统调用 `POST /webhook` 时,在请求头里带上: + +```text +x-bioclaw-secret: change-me +``` + +### 4. 构建容器镜像 + +```bash +docker build -t bioclaw-agent:latest ./container +``` + +### 5. 启动本地网页聊天 + +```bash +npm run dev +``` + +然后在浏览器打开: + +```text +http://127.0.0.1:3210 +``` + +你会看到一个本地聊天页面,直接输入问题即可。 + +### 6. 如果只想在终端里用,也可以用 CLI 模式运行 + +```bash +npm run cli +``` + +启动后直接输入任务,例如: + +```text +Analyze DNA sequence ATGCGATCG and find ORFs +``` + +网页聊天和 CLI 这两条路径都不依赖 WhatsApp,也不依赖 QQ / 飞书。 + +## 为什么先推荐 CLI + +- 改动最小 +- 对 Windows 最稳 +- 便于先验证 Docker、模型配置、容器工具链是否正常 +- 在 QQ / 飞书通道未实现前,这是最快能交付给用户的使用方式 + +## QQ / 飞书的实际情况 + +当前仓库没有 QQ / 飞书通道源码,因此不能只改配置就直接启用。 + +如果后续要支持中国用户常用平台,建议优先级如下: + +1. 飞书(Lark)Bot / Webhook +2. 企业微信 Bot / 应用回调 +3. QQ Bot + +原因是飞书和企业微信的 Bot / Webhook 方案通常更工程化,接入难度和稳定性都比 QQ 更可控。 diff --git a/src/channels/local-web.ts b/src/channels/local-web.ts new file mode 100644 index 0000000..2e6575a --- /dev/null +++ b/src/channels/local-web.ts @@ -0,0 +1,700 @@ +import fs from 'fs'; +import http, { IncomingMessage, ServerResponse } from 'http'; +import path from 'path'; + +import { + ASSISTANT_NAME, + GROUPS_DIR, + LOCAL_WEB_GROUP_FOLDER, + LOCAL_WEB_GROUP_JID, + LOCAL_WEB_HOST, + LOCAL_WEB_PORT, + LOCAL_WEB_SECRET, +} from '../config.js'; +import { getRecentMessages, storeChatMetadata, storeMessageDirect } from '../db.js'; +import { logger } from '../logger.js'; +import { Channel, OnInboundMessage, OnChatMetadata } from '../types.js'; + +interface LocalWebChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; +} + +interface IncomingPayload { + text?: string; + sender?: string; + senderName?: string; + chatJid?: string; +} + +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + +function sendJson(res: ServerResponse, statusCode: number, data: unknown): void { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(data)); +} + +function readBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on('data', (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(buffer); + size += buffer.length; + if (size > maxBytes) { + reject(new Error('Request body too large')); + req.destroy(); + } + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function sanitizeFileName(filename: string): string { + const basename = path.basename(filename).replace(/[^\w.\-]/g, '_'); + return basename || 'upload.bin'; +} + +function ensureUploadDir(): string { + const uploadDir = path.join(GROUPS_DIR, LOCAL_WEB_GROUP_FOLDER, 'uploads'); + fs.mkdirSync(uploadDir, { recursive: true }); + return uploadDir; +} + +function buildUploadPaths(filename: string): { relativePath: string; absolutePath: string; publicPath: string } { + const safeName = sanitizeFileName(filename); + const storedName = `${Date.now()}-${safeName}`; + const relativePath = path.posix.join('uploads', storedName); + return { + relativePath, + absolutePath: path.join(ensureUploadDir(), storedName), + publicPath: `/files/${relativePath}`, + }; +} + +function isSafeRelativePath(relativePath: string): boolean { + const normalized = path.posix.normalize(relativePath).replace(/^\/+/, ''); + return normalized.length > 0 && !normalized.startsWith('..'); +} + +function renderPage(chatJid: string): string { + return ` + + + + + BioClaw Local Web Chat + + + +
+
+

BioClaw Local Web Chat

+
本地网页聊天入口,适合 Windows + 中国网络环境先验证 BioClaw。当前会话:${escapeHtml(chatJid)}
+
+
+
+ +
+
如果当前群组未要求触发词,可直接发送;默认本地网页聊天不需要 @${escapeHtml(ASSISTANT_NAME)}。
+
+ + 未选择文件 + +
+
+
+
+
+ + +`; +} + +export class LocalWebChannel implements Channel { + name = 'local-web'; + prefixAssistantName = true; + + private server?: http.Server; + private connected = false; + private opts: LocalWebChannelOpts; + + constructor(opts: LocalWebChannelOpts) { + this.opts = opts; + } + + async connect(): Promise { + if (this.server) return; + + this.server = http.createServer(async (req, res) => { + try { + await this.handleRequest(req, res); + } catch (err) { + logger.error({ err }, 'Local web request failed'); + sendJson(res, 500, { error: 'Internal server error' }); + } + }); + + await new Promise((resolve, reject) => { + this.server!.once('error', reject); + this.server!.listen(LOCAL_WEB_PORT, LOCAL_WEB_HOST, () => { + this.server!.off('error', reject); + resolve(); + }); + }); + + this.connected = true; + logger.info( + { host: LOCAL_WEB_HOST, port: LOCAL_WEB_PORT, jid: LOCAL_WEB_GROUP_JID }, + 'Local web channel listening', + ); + } + + isConnected(): boolean { + return this.connected; + } + + ownsJid(jid: string): boolean { + return jid.endsWith('@local.web'); + } + + async disconnect(): Promise { + this.connected = false; + if (!this.server) return; + await new Promise((resolve, reject) => { + this.server!.close((err) => (err ? reject(err) : resolve())); + }); + this.server = undefined; + } + + async sendMessage(jid: string, text: string): Promise { + const now = new Date().toISOString(); + storeChatMetadata(jid, now, jid); + storeMessageDirect({ + id: `local-web-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + chat_jid: jid, + sender: 'bioclaw@local.web', + sender_name: ASSISTANT_NAME, + content: text, + timestamp: now, + is_from_me: true, + }); + } + + async sendImage(jid: string, imagePath: string, caption?: string): Promise { + const filename = path.basename(imagePath); + const fallback = caption + ? `${caption}\n[Image generated: ${filename}]` + : `[Image generated: ${filename}]`; + await this.sendMessage(jid, fallback); + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === 'GET' && url.pathname === '/') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderPage(LOCAL_WEB_GROUP_JID)); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/messages') { + const chatJid = url.searchParams.get('chatJid') || LOCAL_WEB_GROUP_JID; + sendJson(res, 200, { messages: getRecentMessages(chatJid, 100) }); + return; + } + + if (req.method === 'GET' && url.pathname.startsWith('/files/')) { + const relativePath = url.pathname.slice('/files/'.length); + if (!isSafeRelativePath(relativePath)) { + sendJson(res, 400, { error: 'Invalid file path' }); + return; + } + const absolutePath = path.join(GROUPS_DIR, LOCAL_WEB_GROUP_FOLDER, relativePath); + if (!absolutePath.startsWith(path.join(GROUPS_DIR, LOCAL_WEB_GROUP_FOLDER))) { + sendJson(res, 403, { error: 'Forbidden' }); + return; + } + if (!fs.existsSync(absolutePath)) { + sendJson(res, 404, { error: 'File not found' }); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(fs.readFileSync(absolutePath)); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/messages') { + const body = (await readBody(req)).toString('utf-8'); + const payload = JSON.parse(body || '{}') as IncomingPayload; + await this.acceptInbound(payload.chatJid || LOCAL_WEB_GROUP_JID, payload.text || '', 'web-user@local.web', 'Web User'); + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/upload') { + const chatJid = url.searchParams.get('chatJid') || LOCAL_WEB_GROUP_JID; + const fileNameHeader = req.headers['x-file-name']; + const originalName = typeof fileNameHeader === 'string' + ? decodeURIComponent(fileNameHeader) + : 'upload.bin'; + const body = await readBody(req, MAX_UPLOAD_BYTES); + if (body.length === 0) { + sendJson(res, 400, { error: 'Empty file upload' }); + return; + } + const paths = buildUploadPaths(originalName); + fs.writeFileSync(paths.absolutePath, body); + await this.acceptInbound( + chatJid, + [ + `Uploaded file: ${originalName}`, + `Workspace path: ${paths.relativePath}`, + `Preview URL: ${paths.publicPath}`, + ].join('\n'), + 'web-user@local.web', + 'Web User', + ); + sendJson(res, 200, { + ok: true, + filename: originalName, + workspacePath: paths.relativePath, + publicPath: paths.publicPath, + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/webhook') { + if (LOCAL_WEB_SECRET) { + const supplied = req.headers['x-bioclaw-secret']; + if (supplied !== LOCAL_WEB_SECRET) { + sendJson(res, 403, { error: 'Forbidden' }); + return; + } + } + const body = (await readBody(req)).toString('utf-8'); + const payload = JSON.parse(body || '{}') as IncomingPayload; + await this.acceptInbound( + payload.chatJid || LOCAL_WEB_GROUP_JID, + payload.text || '', + payload.sender || 'webhook-user@local.web', + payload.senderName || 'Webhook User', + ); + sendJson(res, 200, { ok: true }); + return; + } + + sendJson(res, 404, { error: 'Not found' }); + } + + private async acceptInbound( + chatJid: string, + text: string, + sender: string, + senderName: string, + ): Promise { + const trimmed = text.trim(); + if (!trimmed) return; + + const now = new Date().toISOString(); + this.opts.onChatMetadata(chatJid, now, 'Local Web Chat'); + this.opts.onMessage(chatJid, { + id: `local-web-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + chat_jid: chatJid, + sender, + sender_name: senderName, + content: trimmed, + timestamp: now, + is_from_me: false, + }); + } +} diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index 1aeb58f..1d688ea 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -72,6 +72,11 @@ let fakeSocket: ReturnType; vi.mock('@whiskeysockets/baileys', () => { return { default: vi.fn(() => fakeSocket), + Browsers: { + macOS: vi.fn((browser: string) => ['Mac OS', browser, '14.4.1']), + windows: vi.fn((browser: string) => ['Windows', browser, '10.0.22631']), + appropriate: vi.fn((browser: string) => ['Ubuntu', browser, '22.04.4']), + }, DisconnectReason: { loggedOut: 401, badSession: 500, @@ -126,8 +131,9 @@ function triggerDisconnect(statusCode: number) { }); } -function triggerMessages(messages: unknown[]) { +async function triggerMessages(messages: unknown[]) { fakeSocket._ev.emit('messages.upsert', { messages }); + await new Promise((r) => setTimeout(r, 0)); } // --- Tests --- @@ -297,7 +303,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-1', @@ -332,7 +338,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-2', @@ -359,7 +365,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-3', @@ -381,7 +387,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-4', @@ -402,7 +408,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-5', @@ -430,7 +436,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-6', @@ -458,7 +464,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-7', @@ -486,7 +492,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-8', @@ -515,7 +521,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-9', @@ -556,7 +562,7 @@ describe('WhatsAppChannel', () => { // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' // Send a message from the LID - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-lid', @@ -582,7 +588,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-normal', @@ -608,7 +614,7 @@ describe('WhatsAppChannel', () => { await connectChannel(channel); - triggerMessages([ + await triggerMessages([ { key: { id: 'msg-unknown-lid', diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 1fe73b1..8e11eba 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -1,4 +1,3 @@ -import { exec } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -18,6 +17,7 @@ import { updateChatName, } from '../db.js'; import { logger } from '../logger.js'; +import { getWhatsAppBrowser, notifyAuthRequired } from '../platform.js'; import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js'; const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -74,7 +74,7 @@ export class WhatsAppChannel implements Channel { ...(version ? { version } : {}), printQRInTerminal: false, logger, - browser: Browsers.macOS('Chrome'), + browser: getWhatsAppBrowser('Chrome'), }); this.sock.ev.on('connection.update', (update) => { @@ -84,9 +84,7 @@ export class WhatsAppChannel implements Channel { const msg = 'WhatsApp authentication required. Run /setup in Claude Code.'; logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "BioClaw" sound name "Basso"'`, - ); + notifyAuthRequired(msg); setTimeout(() => process.exit(1), 1000); } diff --git a/src/cli.ts b/src/cli.ts index b12002d..953560e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -70,7 +70,17 @@ function readSecrets(): Record { if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } - if (['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(key) && value) { + if ([ + 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'MODEL_PROVIDER', + 'OPENROUTER_API_KEY', + 'OPENROUTER_BASE_URL', + 'OPENROUTER_MODEL', + 'OPENAI_COMPATIBLE_API_KEY', + 'OPENAI_COMPATIBLE_BASE_URL', + 'OPENAI_COMPATIBLE_MODEL', + ].includes(key) && value) { secrets[key] = value; } } diff --git a/src/config.ts b/src/config.ts index e92774e..ba57780 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,27 @@ +import { loadEnvFile } from './env.js'; +import { getHomeDir } from './platform.js'; import path from 'path'; +loadEnvFile(); + export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Bioclaw'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; +export const ENABLE_WHATSAPP = process.env.ENABLE_WHATSAPP !== 'false'; +export const ENABLE_LOCAL_WEB = process.env.ENABLE_LOCAL_WEB === 'true'; +export const LOCAL_WEB_HOST = process.env.LOCAL_WEB_HOST || '127.0.0.1'; +export const LOCAL_WEB_PORT = parseInt(process.env.LOCAL_WEB_PORT || '3210', 10); +export const LOCAL_WEB_GROUP_JID = + process.env.LOCAL_WEB_GROUP_JID || 'local-web@local.web'; +export const LOCAL_WEB_GROUP_NAME = + process.env.LOCAL_WEB_GROUP_NAME || 'Local Web Chat'; +export const LOCAL_WEB_GROUP_FOLDER = + process.env.LOCAL_WEB_GROUP_FOLDER || 'local-web'; +export const LOCAL_WEB_SECRET = process.env.LOCAL_WEB_SECRET || ''; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); -const HOME_DIR = process.env.HOME || '/Users/user'; +const HOME_DIR = getHomeDir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers export const MOUNT_ALLOWLIST_PATH = path.join( diff --git a/src/container-runner.ts b/src/container-runner.ts index a398daf..c3ac94e 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -4,7 +4,6 @@ */ import { ChildProcess, exec, spawn } from 'child_process'; import fs from 'fs'; -import os from 'os'; import path from 'path'; import { @@ -17,22 +16,13 @@ import { } from './config.js'; import { logger } from './logger.js'; import { validateAdditionalMounts } from './mount-security.js'; +import { getHomeDir } from './platform.js'; import { RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) const OUTPUT_START_MARKER = '---BIOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---BIOCLAW_OUTPUT_END---'; -function getHomeDir(): string { - const home = process.env.HOME || os.homedir(); - if (!home) { - throw new Error( - 'Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty', - ); - } - return home; -} - export interface ContainerInput { prompt: string; sessionId?: string; diff --git a/src/db.ts b/src/db.ts index c1daa5b..22b2fa7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -276,6 +276,24 @@ export function getMessagesSince( .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; } +export function getRecentMessages( + chatJid: string, + limit = 100, +): NewMessage[] { + return db + .prepare( + ` + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + FROM messages + WHERE chat_jid = ? + ORDER BY timestamp DESC + LIMIT ? + `, + ) + .all(chatJid, limit) + .reverse() as NewMessage[]; +} + export function createTask( task: Omit, ): void { diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..00073c5 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,33 @@ +import fs from 'fs'; +import path from 'path'; + +let loaded = false; + +export function loadEnvFile(): void { + if (loaded) return; + loaded = true; + + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return; + + const content = fs.readFileSync(envPath, 'utf-8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + + const key = trimmed.slice(0, eqIdx).trim(); + if (!key || process.env[key] !== undefined) continue; + + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} diff --git a/src/formatting.test.ts b/src/formatting.test.ts index b35aad6..fed9c3b 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -102,17 +102,17 @@ describe('formatMessages', () => { // --- TRIGGER_PATTERN --- describe('TRIGGER_PATTERN', () => { - it('matches @Bio at start of message', () => { - expect(TRIGGER_PATTERN.test('@Bio hello')).toBe(true); + it(`matches @${ASSISTANT_NAME} at start of message`, () => { + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME} hello`)).toBe(true); }); it('matches case-insensitively', () => { - expect(TRIGGER_PATTERN.test('@bio hello')).toBe(true); - expect(TRIGGER_PATTERN.test('@BIO hello')).toBe(true); + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME.toLowerCase()} hello`)).toBe(true); + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME.toUpperCase()} hello`)).toBe(true); }); it('does not match when not at start of message', () => { - expect(TRIGGER_PATTERN.test('hello @Bio')).toBe(false); + expect(TRIGGER_PATTERN.test(`hello @${ASSISTANT_NAME}`)).toBe(false); }); it('does not match partial name like @Andrew (word boundary)', () => { @@ -120,16 +120,16 @@ describe('TRIGGER_PATTERN', () => { }); it('matches with word boundary before apostrophe', () => { - expect(TRIGGER_PATTERN.test("@Bio's thing")).toBe(true); + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME}'s thing`)).toBe(true); }); - it('matches @Bio alone (end of string is a word boundary)', () => { - expect(TRIGGER_PATTERN.test('@Bio')).toBe(true); + it(`matches @${ASSISTANT_NAME} alone (end of string is a word boundary)`, () => { + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME}`)).toBe(true); }); it('matches with leading whitespace after trim', () => { // The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim()) - expect(TRIGGER_PATTERN.test('@Bio hey'.trim())).toBe(true); + expect(TRIGGER_PATTERN.test(`@${ASSISTANT_NAME} hey`.trim())).toBe(true); }); }); @@ -235,7 +235,7 @@ describe('trigger gating (requiresTrigger interaction)', () => { }); it('non-main group with requiresTrigger=true processes when trigger present', () => { - const msgs = [makeMsg({ content: '@Bio do something' })]; + const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; expect(shouldProcess(false, true, msgs)).toBe(true); }); diff --git a/src/index.ts b/src/index.ts index 1a400d1..558bf4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,17 @@ import path from 'path'; import { ASSISTANT_NAME, DATA_DIR, + ENABLE_LOCAL_WEB, + ENABLE_WHATSAPP, IDLE_TIMEOUT, + LOCAL_WEB_GROUP_FOLDER, + LOCAL_WEB_GROUP_JID, + LOCAL_WEB_GROUP_NAME, MAIN_GROUP_FOLDER, POLL_INTERVAL, TRIGGER_PATTERN, } from './config.js'; +import { LocalWebChannel } from './channels/local-web.js'; import { WhatsAppChannel } from './channels/whatsapp.js'; import { ContainerOutput, @@ -34,9 +40,9 @@ import { } from './db.js'; import { GroupQueue } from './group-queue.js'; import { startIpcWatcher } from './ipc.js'; -import { formatMessages, formatOutbound } from './router.js'; +import { findChannel, formatMessages, formatOutbound } from './router.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { NewMessage, RegisteredGroup } from './types.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; // Re-export for backwards compatibility during refactor @@ -48,7 +54,7 @@ let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; let messageLoopRunning = false; -let whatsapp: WhatsAppChannel; +const channels: Channel[] = []; const queue = new GroupQueue(); function loadState(): void { @@ -99,7 +105,11 @@ export function getAvailableGroups(): import('./container-runner.js').AvailableG const registeredJids = new Set(Object.keys(registeredGroups)); return chats - .filter((c) => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us')) + .filter( + (c) => + c.jid !== '__group_sync__' && + (c.jid.endsWith('@g.us') || c.jid.endsWith('@local.web')), + ) .map((c) => ({ jid: c.jid, name: c.name, @@ -120,6 +130,11 @@ export function _setRegisteredGroups(groups: Record): v async function processGroupMessages(chatJid: string): Promise { const group = registeredGroups[chatJid]; if (!group) return true; + const channel = findChannel(channels, chatJid); + if (!channel) { + logger.warn({ chatJid }, 'No channel found for group'); + return true; + } const isMainGroup = group.folder === MAIN_GROUP_FOLDER; @@ -165,7 +180,7 @@ async function processGroupMessages(chatJid: string): Promise { }, IDLE_TIMEOUT); }; - await whatsapp.setTyping(chatJid, true); + await channel.setTyping?.(chatJid, true); let hadError = false; let outputSentToUser = false; @@ -177,7 +192,7 @@ async function processGroupMessages(chatJid: string): Promise { const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); if (text) { - await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`); + await channel.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`); outputSentToUser = true; } // Only reset idle timer on actual results, not session-update markers (result: null) @@ -189,7 +204,7 @@ async function processGroupMessages(chatJid: string): Promise { } }); - await whatsapp.setTyping(chatJid, false); + await channel.setTyping?.(chatJid, false); if (idleTimer) clearTimeout(idleTimer); if (output === 'error' || hadError) { @@ -403,6 +418,7 @@ function ensureDockerRunning(): void { console.error('║ Agents cannot run without Docker. To fix: ║'); console.error('║ macOS: Start Docker Desktop ║'); console.error('║ Linux: sudo systemctl start docker ║'); + console.error('║ Windows: Start Docker Desktop (prefer WSL2 backend) ║'); console.error('║ ║'); console.error('║ Install from: https://docker.com/products/docker-desktop ║'); console.error('╚════════════════════════════════════════════════════════════════╝\n'); @@ -439,21 +455,63 @@ async function main(): Promise { const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); await queue.shutdown(10000); - await whatsapp.disconnect(); + await Promise.all(channels.map((channel) => channel.disconnect())); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); - // Create WhatsApp channel - whatsapp = new WhatsAppChannel({ - onMessage: (chatJid, msg) => storeMessage(msg), - onChatMetadata: (chatJid, timestamp) => storeChatMetadata(chatJid, timestamp), - registeredGroups: () => registeredGroups, - }); + if (ENABLE_WHATSAPP) { + channels.push( + new WhatsAppChannel({ + onMessage: (_chatJid, msg) => storeMessage(msg), + onChatMetadata: (chatJid, timestamp) => storeChatMetadata(chatJid, timestamp), + registeredGroups: () => registeredGroups, + }), + ); + } + + if (ENABLE_LOCAL_WEB) { + if (!registeredGroups[LOCAL_WEB_GROUP_JID]) { + const folderConflict = Object.entries(registeredGroups).find( + ([jid, group]) => jid !== LOCAL_WEB_GROUP_JID && group.folder === LOCAL_WEB_GROUP_FOLDER, + ); + if (folderConflict) { + logger.warn( + { + localWebJid: LOCAL_WEB_GROUP_JID, + folder: LOCAL_WEB_GROUP_FOLDER, + conflictJid: folderConflict[0], + }, + 'Skipping local web auto-registration because folder is already in use', + ); + } else { + registerGroup(LOCAL_WEB_GROUP_JID, { + name: LOCAL_WEB_GROUP_NAME, + folder: LOCAL_WEB_GROUP_FOLDER, + trigger: `@${ASSISTANT_NAME}`, + added_at: new Date().toISOString(), + requiresTrigger: false, + }); + } + } + + channels.push( + new LocalWebChannel({ + onMessage: (_chatJid, msg) => storeMessage(msg), + onChatMetadata: (chatJid, timestamp, name) => + storeChatMetadata(chatJid, timestamp, name), + }), + ); + } - // Connect — resolves when first connected - await whatsapp.connect(); + if (channels.length === 0) { + throw new Error('No channel enabled. Set ENABLE_LOCAL_WEB=true or enable WhatsApp.'); + } + + for (const channel of channels) { + await channel.connect(); + } // Start subsystems (independently of connection handler) startSchedulerLoop({ @@ -462,16 +520,39 @@ async function main(): Promise { queue, onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText) => { - const text = formatOutbound(whatsapp, rawText); - if (text) await whatsapp.sendMessage(jid, text); + const channel = findChannel(channels, jid); + if (!channel) { + logger.warn({ jid }, 'No outbound channel found for scheduled task'); + return; + } + const text = formatOutbound(channel, rawText); + if (text) await channel.sendMessage(jid, text); }, }); startIpcWatcher({ - sendMessage: (jid, text) => whatsapp.sendMessage(jid, text), - sendImage: (jid, imagePath, caption) => whatsapp.sendImage(jid, imagePath, caption), + sendMessage: async (jid, text) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No outbound channel for JID: ${jid}`); + await channel.sendMessage(jid, text); + }, + sendImage: async (jid, imagePath, caption) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No outbound channel for JID: ${jid}`); + if (channel.sendImage) { + await channel.sendImage(jid, imagePath, caption); + } else { + const fallback = caption + ? `${caption}\n[Image saved at ${imagePath}]` + : `[Image saved at ${imagePath}]`; + await channel.sendMessage(jid, fallback); + } + }, registeredGroups: () => registeredGroups, registerGroup, - syncGroupMetadata: (force) => whatsapp.syncGroupMetadata(force), + syncGroupMetadata: async (force) => { + const whatsapp = channels.find((c) => c.name === 'whatsapp') as WhatsAppChannel | undefined; + if (whatsapp) await whatsapp.syncGroupMetadata(force); + }, getAvailableGroups, writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), }); diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..2043fcb --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,27 @@ +import { exec } from 'child_process'; +import os from 'os'; + +import { Browsers } from '@whiskeysockets/baileys'; + +export function getHomeDir(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir(); +} + +export function getWhatsAppBrowser(browser = 'Chrome'): [string, string, string] { + if (process.platform === 'darwin') { + return Browsers.macOS(browser); + } + if (process.platform === 'win32') { + return Browsers.windows(browser); + } + return Browsers.appropriate(browser); +} + +export function notifyAuthRequired(message: string): void { + if (process.platform !== 'darwin') return; + + const escaped = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + exec( + `osascript -e 'display notification "${escaped}" with title "BioClaw" sound name "Basso"'`, + ); +} diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index 5119ce8..4f358da 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -13,13 +13,14 @@ import qrcode from 'qrcode-terminal'; import readline from 'readline'; import makeWASocket, { - Browsers, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, useMultiFileAuthState, } from '@whiskeysockets/baileys'; +import { getWhatsAppBrowser } from './platform.js'; + const AUTH_DIR = './store/auth'; const QR_FILE = './store/qr-data.txt'; const STATUS_FILE = './store/auth-status.txt'; @@ -74,7 +75,7 @@ async function connectSocket(phoneNumber?: string): Promise { ...(version ? { version } : {}), printQRInTerminal: false, logger, - browser: Browsers.macOS('Chrome'), + browser: getWhatsAppBrowser('Chrome'), }); if (usePairingCode && phoneNumber && !pairingCodeRequested) {