Compare commits
10 Commits
49595b9c70
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e24a31bd4 | ||
|
|
c0902877fa | ||
|
|
38ebb31e6d | ||
|
|
fedfaf3f50 | ||
|
|
df9ba0e5f9 | ||
|
|
e6ff5c640c | ||
|
|
6f64b31d03 | ||
|
|
c7391757ac | ||
|
|
3414625a6d | ||
|
|
2a90f98138 |
16
README.md
16
README.md
@@ -13,10 +13,10 @@
|
|||||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
---
|
||||||
<br>
|
|
||||||
<h2>🐳 Now Running in Docker Sandboxes</h2>
|
<h2 align="center">🐳 Now Runs in Docker Sandboxes</h2>
|
||||||
<p>Every agent gets its own isolated container inside a micro VM.<br>Hypervisor-level isolation. Millisecond startup. No complex setup.</p>
|
<p align="center">Every agent gets its own isolated container inside a micro VM.<br>Hypervisor-level isolation. Millisecond startup. No complex setup.</p>
|
||||||
|
|
||||||
**macOS (Apple Silicon)**
|
**macOS (Apple Silicon)**
|
||||||
```bash
|
```bash
|
||||||
@@ -28,9 +28,11 @@ curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
|
|||||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
|
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
<p><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">Read the announcement →</a></p>
|
> Currently supported on macOS (Apple Silicon) and Windows (x86). Linux support coming soon.
|
||||||
<br>
|
|
||||||
</div>
|
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">Read the announcement →</a> · <a href="docs/docker-sandboxes.md">Manual setup guide →</a></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Why I Built NanoClaw
|
## Why I Built NanoClaw
|
||||||
|
|
||||||
|
|||||||
359
docs/docker-sandboxes.md
Normal file
359
docs/docker-sandboxes.md
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
# Running NanoClaw in Docker Sandboxes (Manual Setup)
|
||||||
|
|
||||||
|
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Host (macOS / Windows WSL)
|
||||||
|
└── Docker Sandbox (micro VM with isolated kernel)
|
||||||
|
├── NanoClaw process (Node.js)
|
||||||
|
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
|
||||||
|
│ └── Container spawner → nested Docker daemon
|
||||||
|
└── Docker-in-Docker
|
||||||
|
└── nanoclaw-agent containers
|
||||||
|
└── Claude Agent SDK
|
||||||
|
```
|
||||||
|
|
||||||
|
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
|
||||||
|
|
||||||
|
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
|
||||||
|
|
||||||
|
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Docker Desktop v4.40+** with Sandbox support
|
||||||
|
- **Anthropic API key** (the sandbox proxy manages injection)
|
||||||
|
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
|
||||||
|
- For **WhatsApp**: a phone with WhatsApp installed
|
||||||
|
|
||||||
|
Verify sandbox support:
|
||||||
|
```bash
|
||||||
|
docker sandbox version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Create the Sandbox
|
||||||
|
|
||||||
|
On your host machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a workspace directory
|
||||||
|
mkdir -p ~/nanoclaw-workspace
|
||||||
|
|
||||||
|
# Create a shell sandbox with the workspace mounted
|
||||||
|
docker sandbox create shell ~/nanoclaw-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker sandbox network proxy shell-nanoclaw-workspace \
|
||||||
|
--bypass-host web.whatsapp.com \
|
||||||
|
--bypass-host "*.whatsapp.com" \
|
||||||
|
--bypass-host "*.whatsapp.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
Telegram does not need proxy bypass.
|
||||||
|
|
||||||
|
Enter the sandbox:
|
||||||
|
```bash
|
||||||
|
docker sandbox run shell-nanoclaw-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Install Prerequisites
|
||||||
|
|
||||||
|
Inside the sandbox:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||||
|
npm config set strict-ssl false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Clone and Install NanoClaw
|
||||||
|
|
||||||
|
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
||||||
|
cd ~
|
||||||
|
git clone https://github.com/qwibitai/nanoclaw.git
|
||||||
|
|
||||||
|
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
||||||
|
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||||
|
|
||||||
|
# Move into workspace so DinD mounts work
|
||||||
|
mv nanoclaw "$WORKSPACE/nanoclaw"
|
||||||
|
cd "$WORKSPACE/nanoclaw"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
npm install https-proxy-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Apply Proxy and Sandbox Patches
|
||||||
|
|
||||||
|
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
|
||||||
|
|
||||||
|
### 4a. Dockerfile — proxy args for container image build
|
||||||
|
|
||||||
|
`npm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
|
||||||
|
|
||||||
|
Add these lines after the `FROM` line:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Accept proxy build args
|
||||||
|
ARG http_proxy
|
||||||
|
ARG https_proxy
|
||||||
|
ARG no_proxy
|
||||||
|
ARG NODE_EXTRA_CA_CERTS
|
||||||
|
ARG npm_config_strict_ssl=true
|
||||||
|
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
||||||
|
```
|
||||||
|
|
||||||
|
And after the `RUN npm install` line:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN npm config set strict-ssl true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. Build script — forward proxy args
|
||||||
|
|
||||||
|
Patch `container/build.sh` to pass proxy env vars to `docker build`:
|
||||||
|
|
||||||
|
Add these `--build-arg` flags to the `docker build` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
||||||
|
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
||||||
|
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
||||||
|
--build-arg npm_config_strict_ssl=false \
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
|
||||||
|
|
||||||
|
Three changes to `src/container-runner.ts`:
|
||||||
|
|
||||||
|
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
|
||||||
|
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
||||||
|
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
||||||
|
// Use emptyEnvPath instead of '/dev/null' in the mount
|
||||||
|
```
|
||||||
|
|
||||||
|
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
|
||||||
|
|
||||||
|
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
||||||
|
if (caCertSrc) {
|
||||||
|
const certDir = path.join(DATA_DIR, 'ca-cert');
|
||||||
|
fs.mkdirSync(certDir, { recursive: true });
|
||||||
|
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
||||||
|
// Mount: certDir -> /workspace/ca-cert (read-only)
|
||||||
|
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4d. Container runtime — prevent self-termination
|
||||||
|
|
||||||
|
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4e. Credential proxy — route through MITM proxy
|
||||||
|
|
||||||
|
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
|
||||||
|
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||||
|
// Pass upstreamAgent to https.request() options
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4f. Setup script — proxy build args
|
||||||
|
|
||||||
|
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
|
||||||
|
|
||||||
|
## Step 5: Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
bash container/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Add a Channel
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply the Telegram skill
|
||||||
|
npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||||
|
|
||||||
|
# Rebuild after applying the skill
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Configure .env
|
||||||
|
cat > .env << EOF
|
||||||
|
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
||||||
|
ASSISTANT_NAME=nanoclaw
|
||||||
|
ANTHROPIC_API_KEY=proxy-managed
|
||||||
|
EOF
|
||||||
|
mkdir -p data/env && cp .env data/env/env
|
||||||
|
|
||||||
|
# Register your chat
|
||||||
|
npx tsx setup/index.ts --step register \
|
||||||
|
--jid "tg:<your-chat-id>" \
|
||||||
|
--name "My Chat" \
|
||||||
|
--trigger "@nanoclaw" \
|
||||||
|
--folder "telegram_main" \
|
||||||
|
--channel telegram \
|
||||||
|
--assistant-name "nanoclaw" \
|
||||||
|
--is-main \
|
||||||
|
--no-trigger-required
|
||||||
|
```
|
||||||
|
|
||||||
|
**To find your chat ID:** Send any message to your bot, then:
|
||||||
|
```bash
|
||||||
|
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
|
||||||
|
|
||||||
|
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
|
||||||
|
|
||||||
|
### WhatsApp
|
||||||
|
|
||||||
|
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply the WhatsApp skill
|
||||||
|
npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Configure .env
|
||||||
|
cat > .env << EOF
|
||||||
|
ASSISTANT_NAME=nanoclaw
|
||||||
|
ANTHROPIC_API_KEY=proxy-managed
|
||||||
|
EOF
|
||||||
|
mkdir -p data/env && cp .env data/env/env
|
||||||
|
|
||||||
|
# Authenticate (choose one):
|
||||||
|
|
||||||
|
# QR code — scan with WhatsApp camera:
|
||||||
|
npx tsx src/whatsapp-auth.ts
|
||||||
|
|
||||||
|
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
|
||||||
|
npx tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
||||||
|
|
||||||
|
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
||||||
|
npx tsx setup/index.ts --step register \
|
||||||
|
--jid "<phone>@s.whatsapp.net" \
|
||||||
|
--name "My Chat" \
|
||||||
|
--trigger "@nanoclaw" \
|
||||||
|
--folder "whatsapp_main" \
|
||||||
|
--channel whatsapp \
|
||||||
|
--assistant-name "nanoclaw" \
|
||||||
|
--is-main \
|
||||||
|
--no-trigger-required
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
|
||||||
|
|
||||||
|
### Both Channels
|
||||||
|
|
||||||
|
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
|
||||||
|
|
||||||
|
## Step 7: Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
|
||||||
|
|
||||||
|
## Networking Details
|
||||||
|
|
||||||
|
### How the proxy works
|
||||||
|
|
||||||
|
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
|
||||||
|
|
||||||
|
### Shared paths for DinD mounts
|
||||||
|
|
||||||
|
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
|
||||||
|
- `/dev/null` → replace with an empty file in the project dir
|
||||||
|
- `/usr/local/share/ca-certificates/` → copy cert to project dir
|
||||||
|
- `/home/agent/` → clone to workspace instead
|
||||||
|
|
||||||
|
### Git clone and virtiofs
|
||||||
|
|
||||||
|
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### npm install fails with SELF_SIGNED_CERT_IN_CHAIN
|
||||||
|
```bash
|
||||||
|
npm config set strict-ssl false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container build fails with proxy errors
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg http_proxy=$http_proxy \
|
||||||
|
--build-arg https_proxy=$https_proxy \
|
||||||
|
-t nanoclaw-agent:latest container/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent containers fail with "path not shared"
|
||||||
|
All bind-mounted paths must be under the workspace directory. Check:
|
||||||
|
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
|
||||||
|
- Is the CA cert copied to the project root?
|
||||||
|
- Has the empty `.env` shadow file been created?
|
||||||
|
|
||||||
|
### Agent containers can't reach Anthropic API
|
||||||
|
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
|
||||||
|
|
||||||
|
### WhatsApp error 405
|
||||||
|
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
|
||||||
|
|
||||||
|
### WhatsApp "Connection failed" immediately
|
||||||
|
Proxy bypass not configured. From the **host**, run:
|
||||||
|
```bash
|
||||||
|
docker sandbox network proxy <sandbox-name> \
|
||||||
|
--bypass-host web.whatsapp.com \
|
||||||
|
--bypass-host "*.whatsapp.com" \
|
||||||
|
--bypass-host "*.whatsapp.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram bot doesn't receive messages
|
||||||
|
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
|
||||||
|
2. Check Group Privacy is disabled in @BotFather if using in groups
|
||||||
|
|
||||||
|
### Git clone fails with "inflate: data stream error"
|
||||||
|
Clone to a non-workspace path first, then move:
|
||||||
|
```bash
|
||||||
|
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
### WhatsApp QR code doesn't display
|
||||||
|
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
|
||||||
|
```bash
|
||||||
|
docker sandbox run shell-nanoclaw-workspace
|
||||||
|
# Then inside:
|
||||||
|
npx tsx src/whatsapp-auth.ts
|
||||||
|
```
|
||||||
1536
package-lock.json
generated
1536
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.1.3",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -8,27 +8,33 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
|
"auth": "tsx src/whatsapp-auth.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"format:fix": "prettier --write \"src/**/*.ts\"",
|
"format:fix": "prettier --write \"src/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"setup": "tsx setup/index.ts",
|
"setup": "tsx setup/index.ts",
|
||||||
"auth": "tsx src/whatsapp-auth.ts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"grammy": "^1.39.3",
|
||||||
|
"openai": "^6.25.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
1024
src/channels/telegram.test.ts
Normal file
1024
src/channels/telegram.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
366
src/channels/telegram.ts
Normal file
366
src/channels/telegram.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { Bot } from 'grammy';
|
||||||
|
|
||||||
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||||
|
import { resolveGroupIpcPath } from '../group-folder.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { transcribeBuffer } from '../transcription.js';
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
OnChatMetadata,
|
||||||
|
OnInboundMessage,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
export interface TelegramChannelOpts {
|
||||||
|
onMessage: OnInboundMessage;
|
||||||
|
onChatMetadata: OnChatMetadata;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
clearSession: (chatJid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramChannel implements Channel {
|
||||||
|
name = 'telegram';
|
||||||
|
|
||||||
|
private bot: Bot | null = null;
|
||||||
|
private opts: TelegramChannelOpts;
|
||||||
|
private botToken: string;
|
||||||
|
|
||||||
|
constructor(botToken: string, opts: TelegramChannelOpts) {
|
||||||
|
this.botToken = botToken;
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a JID from a Telegram chat ID and optional topic thread ID.
|
||||||
|
* Format: "tg:{chatId}" or "tg:{chatId}:{threadId}" for forum topics.
|
||||||
|
*/
|
||||||
|
private buildJid(chatId: number, threadId?: number): string {
|
||||||
|
return threadId ? `tg:${chatId}:${threadId}` : `tg:${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JID back into chatId (string) and optional threadId (number).
|
||||||
|
* Handles negative chat IDs (groups/supergroups start with -100...).
|
||||||
|
*/
|
||||||
|
private parseJid(jid: string): { chatId: string; threadId?: number } {
|
||||||
|
const withoutPrefix = jid.replace(/^tg:/, '');
|
||||||
|
const colonIdx = withoutPrefix.indexOf(':');
|
||||||
|
if (colonIdx === -1) {
|
||||||
|
return { chatId: withoutPrefix };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chatId: withoutPrefix.slice(0, colonIdx),
|
||||||
|
threadId: parseInt(withoutPrefix.slice(colonIdx + 1), 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.bot = new Bot(this.botToken);
|
||||||
|
|
||||||
|
// Command to get chat ID (useful for registration)
|
||||||
|
// In forum topics, also shows the thread ID so the full JID can be used.
|
||||||
|
this.bot.command('chatid', (ctx) => {
|
||||||
|
const chatId = ctx.chat.id;
|
||||||
|
const chatType = ctx.chat.type;
|
||||||
|
const threadId = (ctx.message as any)?.message_thread_id as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const chatJid = this.buildJid(chatId, threadId);
|
||||||
|
const chatName =
|
||||||
|
chatType === 'private'
|
||||||
|
? ctx.from?.first_name || 'Private'
|
||||||
|
: (ctx.chat as any).title || 'Unknown';
|
||||||
|
|
||||||
|
const topicLine = threadId ? `\nThread ID: \`${threadId}\`` : '';
|
||||||
|
ctx.reply(
|
||||||
|
`Chat ID: \`${chatJid}\`${topicLine}\nName: ${chatName}\nType: ${chatType}`,
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to check bot status
|
||||||
|
this.bot.command('ping', (ctx) => {
|
||||||
|
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to clear conversation context (start a new session)
|
||||||
|
this.bot.command('reset', (ctx) => {
|
||||||
|
const threadId = (ctx.message as any)?.message_thread_id as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
this.opts.clearSession(chatJid);
|
||||||
|
ctx.reply('Session cleared. Next message starts a fresh conversation.');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bot.on('message:text', async (ctx) => {
|
||||||
|
// Skip commands
|
||||||
|
if (ctx.message.text.startsWith('/')) return;
|
||||||
|
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
let content = ctx.message.text;
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const sender = ctx.from?.id.toString() || '';
|
||||||
|
const msgId = ctx.message.message_id.toString();
|
||||||
|
|
||||||
|
// Determine chat name
|
||||||
|
const chatName =
|
||||||
|
ctx.chat.type === 'private'
|
||||||
|
? senderName
|
||||||
|
: (ctx.chat as any).title || chatJid;
|
||||||
|
|
||||||
|
// In private DMs every message is implicitly addressed to the bot —
|
||||||
|
// no @mention entity exists. Prepend the trigger so TRIGGER_PATTERN matches.
|
||||||
|
if (ctx.chat.type === 'private' && !TRIGGER_PATTERN.test(content)) {
|
||||||
|
content = `@${ASSISTANT_NAME} ${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
||||||
|
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
||||||
|
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
||||||
|
const botUsername = ctx.me?.username?.toLowerCase();
|
||||||
|
if (botUsername) {
|
||||||
|
const entities = ctx.message.entities || [];
|
||||||
|
const isBotMentioned = entities.some((entity) => {
|
||||||
|
if (entity.type === 'mention') {
|
||||||
|
const mentionText = content
|
||||||
|
.substring(entity.offset, entity.offset + entity.length)
|
||||||
|
.toLowerCase();
|
||||||
|
return mentionText === `@${botUsername}`;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
||||||
|
content = `@${ASSISTANT_NAME} ${content}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store chat metadata for discovery
|
||||||
|
this.opts.onChatMetadata(chatJid, timestamp, chatName);
|
||||||
|
|
||||||
|
// Only deliver full message for registered groups
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) {
|
||||||
|
logger.debug(
|
||||||
|
{ chatJid, chatName },
|
||||||
|
'Message from unregistered Telegram chat',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver message — startMessageLoop() will pick it up
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: msgId,
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender,
|
||||||
|
sender_name: senderName,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, chatName, sender: senderName },
|
||||||
|
'Telegram message stored',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-text messages with placeholders so the agent knows something was sent
|
||||||
|
const storeNonText = (ctx: any, placeholder: string) => {
|
||||||
|
const threadId = ctx.message?.message_thread_id as number | undefined;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id?.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
|
||||||
|
|
||||||
|
this.opts.onChatMetadata(chatJid, timestamp);
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: ctx.message.message_id.toString(),
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: ctx.from?.id?.toString() || '',
|
||||||
|
sender_name: senderName,
|
||||||
|
content: `${placeholder}${caption}`,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bot.on('message:photo', async (ctx) => {
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
let placeholder = '[Photo]';
|
||||||
|
try {
|
||||||
|
// Highest resolution is last in the array
|
||||||
|
const photos = ctx.message.photo;
|
||||||
|
const largest = photos[photos.length - 1];
|
||||||
|
const file = await ctx.api.getFile(largest.file_id);
|
||||||
|
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const ext = path.extname(file.file_path || '') || '.jpg';
|
||||||
|
const filename = `photo_${ctx.message.message_id}${ext}`;
|
||||||
|
const inputDir = path.join(
|
||||||
|
resolveGroupIpcPath(group.folder),
|
||||||
|
'input',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(inputDir, filename), buffer);
|
||||||
|
placeholder = `[Photo: /workspace/ipc/input/${filename} — use the Read tool to view this image]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, bytes: buffer.length },
|
||||||
|
'Downloaded Telegram photo',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ chatJid, status: res.status },
|
||||||
|
'Failed to download Telegram photo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Telegram photo download error');
|
||||||
|
}
|
||||||
|
storeNonText(ctx, placeholder);
|
||||||
|
});
|
||||||
|
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
|
||||||
|
this.bot.on('message:voice', async (ctx) => {
|
||||||
|
const threadId = ctx.message.message_thread_id;
|
||||||
|
const chatJid = this.buildJid(ctx.chat.id, threadId);
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
let placeholder = '[Voice Message - transcription unavailable]';
|
||||||
|
try {
|
||||||
|
const file = await ctx.getFile();
|
||||||
|
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, bytes: buffer.length },
|
||||||
|
'Downloaded Telegram voice file',
|
||||||
|
);
|
||||||
|
const transcript = await transcribeBuffer(buffer);
|
||||||
|
if (transcript) {
|
||||||
|
placeholder = `[Voice: ${transcript}]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, length: transcript.length },
|
||||||
|
'Transcribed voice message',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn({ chatJid }, 'Voice transcription returned null');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ chatJid, status: res.status },
|
||||||
|
'Failed to download Telegram voice file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Telegram voice transcription error');
|
||||||
|
placeholder = '[Voice Message - transcription failed]';
|
||||||
|
}
|
||||||
|
storeNonText(ctx, placeholder);
|
||||||
|
});
|
||||||
|
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
|
||||||
|
this.bot.on('message:document', (ctx) => {
|
||||||
|
const name = ctx.message.document?.file_name || 'file';
|
||||||
|
storeNonText(ctx, `[Document: ${name}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:sticker', (ctx) => {
|
||||||
|
const emoji = ctx.message.sticker?.emoji || '';
|
||||||
|
storeNonText(ctx, `[Sticker ${emoji}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
|
||||||
|
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
|
||||||
|
|
||||||
|
// Handle errors gracefully
|
||||||
|
this.bot.catch((err) => {
|
||||||
|
logger.error({ err: err.message }, 'Telegram bot error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling — returns a Promise that resolves when started
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.bot!.start({
|
||||||
|
onStart: (botInfo) => {
|
||||||
|
logger.info(
|
||||||
|
{ username: botInfo.username, id: botInfo.id },
|
||||||
|
'Telegram bot connected',
|
||||||
|
);
|
||||||
|
console.log(`\n Telegram bot: @${botInfo.username}`);
|
||||||
|
console.log(
|
||||||
|
` Send /chatid to the bot to get a chat's registration ID\n`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(jid: string, text: string): Promise<void> {
|
||||||
|
if (!this.bot) {
|
||||||
|
logger.warn('Telegram bot not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { chatId, threadId } = this.parseJid(jid);
|
||||||
|
|
||||||
|
// Telegram has a 4096 character limit per message — split if needed
|
||||||
|
const MAX_LENGTH = 4096;
|
||||||
|
const opts = threadId ? { message_thread_id: threadId } : {};
|
||||||
|
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||||
|
await this.bot.api.sendMessage(chatId, text.slice(i, i + MAX_LENGTH), opts);
|
||||||
|
}
|
||||||
|
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ jid, err }, 'Failed to send Telegram message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.bot !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownsJid(jid: string): boolean {
|
||||||
|
return jid.startsWith('tg:');
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.bot) {
|
||||||
|
this.bot.stop();
|
||||||
|
this.bot = null;
|
||||||
|
logger.info('Telegram bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||||
|
if (!this.bot || !isTyping) return;
|
||||||
|
try {
|
||||||
|
const { chatId, threadId } = this.parseJid(jid);
|
||||||
|
const opts = threadId ? { message_thread_id: threadId } : {};
|
||||||
|
await this.bot.api.sendChatAction(chatId, 'typing', opts);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1032
src/channels/whatsapp.test.ts
Normal file
1032
src/channels/whatsapp.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
400
src/channels/whatsapp.ts
Normal file
400
src/channels/whatsapp.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import makeWASocket, {
|
||||||
|
Browsers,
|
||||||
|
DisconnectReason,
|
||||||
|
WASocket,
|
||||||
|
fetchLatestWaWebVersion,
|
||||||
|
makeCacheableSignalKeyStore,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
} from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ASSISTANT_HAS_OWN_NUMBER,
|
||||||
|
ASSISTANT_NAME,
|
||||||
|
STORE_DIR,
|
||||||
|
} from '../config.js';
|
||||||
|
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js';
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
OnInboundMessage,
|
||||||
|
OnChatMetadata,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export interface WhatsAppChannelOpts {
|
||||||
|
onMessage: OnInboundMessage;
|
||||||
|
onChatMetadata: OnChatMetadata;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WhatsAppChannel implements Channel {
|
||||||
|
name = 'whatsapp';
|
||||||
|
|
||||||
|
private sock!: WASocket;
|
||||||
|
private connected = false;
|
||||||
|
private lidToPhoneMap: Record<string, string> = {};
|
||||||
|
private outgoingQueue: Array<{ jid: string; text: string }> = [];
|
||||||
|
private flushing = false;
|
||||||
|
private groupSyncTimerStarted = false;
|
||||||
|
|
||||||
|
private opts: WhatsAppChannelOpts;
|
||||||
|
|
||||||
|
constructor(opts: WhatsAppChannelOpts) {
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.connectInternal(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
|
||||||
|
const authDir = path.join(STORE_DIR, 'auth');
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
|
||||||
|
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||||
|
|
||||||
|
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
'Failed to fetch latest WA Web version, using default',
|
||||||
|
);
|
||||||
|
return { version: undefined };
|
||||||
|
});
|
||||||
|
this.sock = makeWASocket({
|
||||||
|
version,
|
||||||
|
auth: {
|
||||||
|
creds: state.creds,
|
||||||
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
|
},
|
||||||
|
printQRInTerminal: false,
|
||||||
|
logger,
|
||||||
|
browser: Browsers.macOS('Chrome'),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sock.ev.on('connection.update', (update) => {
|
||||||
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
|
if (qr) {
|
||||||
|
const msg =
|
||||||
|
'WhatsApp authentication required. Run /setup in Claude Code.';
|
||||||
|
logger.error(msg);
|
||||||
|
exec(
|
||||||
|
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
|
||||||
|
);
|
||||||
|
setTimeout(() => process.exit(1), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'close') {
|
||||||
|
this.connected = false;
|
||||||
|
const reason = (
|
||||||
|
lastDisconnect?.error as { output?: { statusCode?: number } }
|
||||||
|
)?.output?.statusCode;
|
||||||
|
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
reason,
|
||||||
|
shouldReconnect,
|
||||||
|
queuedMessages: this.outgoingQueue.length,
|
||||||
|
},
|
||||||
|
'Connection closed',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldReconnect) {
|
||||||
|
logger.info('Reconnecting...');
|
||||||
|
this.connectInternal().catch((err) => {
|
||||||
|
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connectInternal().catch((err2) => {
|
||||||
|
logger.error({ err: err2 }, 'Reconnection retry failed');
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Logged out. Run /setup to re-authenticate.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} else if (connection === 'open') {
|
||||||
|
this.connected = true;
|
||||||
|
logger.info('Connected to WhatsApp');
|
||||||
|
|
||||||
|
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
|
||||||
|
this.sock.sendPresenceUpdate('available').catch((err) => {
|
||||||
|
logger.warn({ err }, 'Failed to send presence update');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build LID to phone mapping from auth state for self-chat translation
|
||||||
|
if (this.sock.user) {
|
||||||
|
const phoneUser = this.sock.user.id.split(':')[0];
|
||||||
|
const lidUser = this.sock.user.lid?.split(':')[0];
|
||||||
|
if (lidUser && phoneUser) {
|
||||||
|
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
|
||||||
|
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any messages queued while disconnected
|
||||||
|
this.flushOutgoingQueue().catch((err) =>
|
||||||
|
logger.error({ err }, 'Failed to flush outgoing queue'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync group metadata on startup (respects 24h cache)
|
||||||
|
this.syncGroupMetadata().catch((err) =>
|
||||||
|
logger.error({ err }, 'Initial group sync failed'),
|
||||||
|
);
|
||||||
|
// Set up daily sync timer (only once)
|
||||||
|
if (!this.groupSyncTimerStarted) {
|
||||||
|
this.groupSyncTimerStarted = true;
|
||||||
|
setInterval(() => {
|
||||||
|
this.syncGroupMetadata().catch((err) =>
|
||||||
|
logger.error({ err }, 'Periodic group sync failed'),
|
||||||
|
);
|
||||||
|
}, GROUP_SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal first connection to caller
|
||||||
|
if (onFirstOpen) {
|
||||||
|
onFirstOpen();
|
||||||
|
onFirstOpen = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sock.ev.on('creds.update', saveCreds);
|
||||||
|
|
||||||
|
this.sock.ev.on('messages.upsert', async ({ messages }) => {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.message) continue;
|
||||||
|
const rawJid = msg.key.remoteJid;
|
||||||
|
if (!rawJid || rawJid === 'status@broadcast') continue;
|
||||||
|
|
||||||
|
// Translate LID JID to phone JID if applicable
|
||||||
|
const chatJid = await this.translateJid(rawJid);
|
||||||
|
|
||||||
|
const timestamp = new Date(
|
||||||
|
Number(msg.messageTimestamp) * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
// Always notify about chat metadata for group discovery
|
||||||
|
const isGroup = chatJid.endsWith('@g.us');
|
||||||
|
this.opts.onChatMetadata(
|
||||||
|
chatJid,
|
||||||
|
timestamp,
|
||||||
|
undefined,
|
||||||
|
'whatsapp',
|
||||||
|
isGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only deliver full message for registered groups
|
||||||
|
const groups = this.opts.registeredGroups();
|
||||||
|
if (groups[chatJid]) {
|
||||||
|
const content =
|
||||||
|
msg.message?.conversation ||
|
||||||
|
msg.message?.extendedTextMessage?.text ||
|
||||||
|
msg.message?.imageMessage?.caption ||
|
||||||
|
msg.message?.videoMessage?.caption ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
|
||||||
|
// but allow voice messages through for transcription
|
||||||
|
if (!content && !isVoiceMessage(msg)) continue;
|
||||||
|
|
||||||
|
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||||
|
const senderName = msg.pushName || sender.split('@')[0];
|
||||||
|
|
||||||
|
const fromMe = msg.key.fromMe || false;
|
||||||
|
// Detect bot messages: with own number, fromMe is reliable
|
||||||
|
// since only the bot sends from that number.
|
||||||
|
// With shared number, bot messages carry the assistant name prefix
|
||||||
|
// (even in DMs/self-chat) so we check for that.
|
||||||
|
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
|
||||||
|
? fromMe
|
||||||
|
: content.startsWith(`${ASSISTANT_NAME}:`);
|
||||||
|
|
||||||
|
// Transcribe voice messages before storing
|
||||||
|
let finalContent = content;
|
||||||
|
if (isVoiceMessage(msg)) {
|
||||||
|
try {
|
||||||
|
const transcript = await transcribeAudioMessage(msg, this.sock);
|
||||||
|
if (transcript) {
|
||||||
|
finalContent = `[Voice: ${transcript}]`;
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, length: transcript.length },
|
||||||
|
'Transcribed voice message',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
finalContent = '[Voice Message - transcription unavailable]';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Voice transcription error');
|
||||||
|
finalContent = '[Voice Message - transcription failed]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: msg.key.id || '',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender,
|
||||||
|
sender_name: senderName,
|
||||||
|
content: finalContent,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: fromMe,
|
||||||
|
is_bot_message: isBotMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(jid: string, text: string): Promise<void> {
|
||||||
|
// Prefix bot messages with assistant name so users know who's speaking.
|
||||||
|
// On a shared number, prefix is also needed in DMs (including self-chat)
|
||||||
|
// to distinguish bot output from user messages.
|
||||||
|
// Skip only when the assistant has its own dedicated phone number.
|
||||||
|
const prefixed = ASSISTANT_HAS_OWN_NUMBER
|
||||||
|
? text
|
||||||
|
: `${ASSISTANT_NAME}: ${text}`;
|
||||||
|
|
||||||
|
if (!this.connected) {
|
||||||
|
this.outgoingQueue.push({ jid, text: prefixed });
|
||||||
|
logger.info(
|
||||||
|
{ jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
|
||||||
|
'WA disconnected, message queued',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.sock.sendMessage(jid, { text: prefixed });
|
||||||
|
logger.info({ jid, length: prefixed.length }, 'Message sent');
|
||||||
|
} catch (err) {
|
||||||
|
// If send fails, queue it for retry on reconnect
|
||||||
|
this.outgoingQueue.push({ jid, text: prefixed });
|
||||||
|
logger.warn(
|
||||||
|
{ jid, err, queueSize: this.outgoingQueue.length },
|
||||||
|
'Failed to send, message queued',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownsJid(jid: string): boolean {
|
||||||
|
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
this.connected = false;
|
||||||
|
this.sock?.end(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = isTyping ? 'composing' : 'paused';
|
||||||
|
logger.debug({ jid, status }, 'Sending presence update');
|
||||||
|
await this.sock.sendPresenceUpdate(status, jid);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ jid, err }, 'Failed to update typing status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync group metadata from WhatsApp.
|
||||||
|
* Fetches all participating groups and stores their names in the database.
|
||||||
|
* Called on startup, daily, and on-demand via IPC.
|
||||||
|
*/
|
||||||
|
async syncGroupMetadata(force = false): Promise<void> {
|
||||||
|
if (!force) {
|
||||||
|
const lastSync = getLastGroupSync();
|
||||||
|
if (lastSync) {
|
||||||
|
const lastSyncTime = new Date(lastSync).getTime();
|
||||||
|
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
|
||||||
|
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Syncing group metadata from WhatsApp...');
|
||||||
|
const groups = await this.sock.groupFetchAllParticipating();
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const [jid, metadata] of Object.entries(groups)) {
|
||||||
|
if (metadata.subject) {
|
||||||
|
updateChatName(jid, metadata.subject);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastGroupSync();
|
||||||
|
logger.info({ count }, 'Group metadata synced');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to sync group metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async translateJid(jid: string): Promise<string> {
|
||||||
|
if (!jid.endsWith('@lid')) return jid;
|
||||||
|
const lidUser = jid.split('@')[0].split(':')[0];
|
||||||
|
|
||||||
|
// Check local cache first
|
||||||
|
const cached = this.lidToPhoneMap[lidUser];
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(
|
||||||
|
{ lidJid: jid, phoneJid: cached },
|
||||||
|
'Translated LID to phone JID (cached)',
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query Baileys' signal repository for the mapping
|
||||||
|
try {
|
||||||
|
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
|
||||||
|
if (pn) {
|
||||||
|
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||||
|
this.lidToPhoneMap[lidUser] = phoneJid;
|
||||||
|
logger.info(
|
||||||
|
{ lidJid: jid, phoneJid },
|
||||||
|
'Translated LID to phone JID (signalRepository)',
|
||||||
|
);
|
||||||
|
return phoneJid;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushOutgoingQueue(): Promise<void> {
|
||||||
|
if (this.flushing || this.outgoingQueue.length === 0) return;
|
||||||
|
this.flushing = true;
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
{ count: this.outgoingQueue.length },
|
||||||
|
'Flushing outgoing message queue',
|
||||||
|
);
|
||||||
|
while (this.outgoingQueue.length > 0) {
|
||||||
|
const item = this.outgoingQueue.shift()!;
|
||||||
|
// Send directly — queued items are already prefixed by sendMessage
|
||||||
|
await this.sock.sendMessage(item.jid, { text: item.text });
|
||||||
|
logger.info(
|
||||||
|
{ jid: item.jid, length: item.text.length },
|
||||||
|
'Queued message sent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
|
|
||||||
// Read config values from .env (falls back to process.env).
|
// Read config values from .env (falls back to process.env).
|
||||||
// Secrets (API keys, tokens) are NOT read here — they are loaded only
|
// Secrets are NOT read here — they stay on disk and are loaded only
|
||||||
// by the credential proxy (credential-proxy.ts), never exposed to containers.
|
// where needed (container-runner.ts) to avoid leaking to child processes.
|
||||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
const envConfig = readEnvFile([
|
||||||
|
'ASSISTANT_NAME',
|
||||||
|
'ASSISTANT_HAS_OWN_NUMBER',
|
||||||
|
'TELEGRAM_BOT_TOKEN',
|
||||||
|
'TELEGRAM_ONLY',
|
||||||
|
]);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||||
@@ -18,7 +22,7 @@ export const SCHEDULER_POLL_INTERVAL = 60000;
|
|||||||
|
|
||||||
// Absolute paths needed for container mounts
|
// Absolute paths needed for container mounts
|
||||||
const PROJECT_ROOT = process.cwd();
|
const PROJECT_ROOT = process.cwd();
|
||||||
const HOME_DIR = process.env.HOME || os.homedir();
|
const HOME_DIR = process.env.HOME || '/Users/user';
|
||||||
|
|
||||||
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
|
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
|
||||||
export const MOUNT_ALLOWLIST_PATH = path.join(
|
export const MOUNT_ALLOWLIST_PATH = path.join(
|
||||||
@@ -27,15 +31,10 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
|
|||||||
'nanoclaw',
|
'nanoclaw',
|
||||||
'mount-allowlist.json',
|
'mount-allowlist.json',
|
||||||
);
|
);
|
||||||
export const SENDER_ALLOWLIST_PATH = path.join(
|
|
||||||
HOME_DIR,
|
|
||||||
'.config',
|
|
||||||
'nanoclaw',
|
|
||||||
'sender-allowlist.json',
|
|
||||||
);
|
|
||||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||||
|
export const MAIN_GROUP_FOLDER = 'main';
|
||||||
|
|
||||||
export const CONTAINER_IMAGE =
|
export const CONTAINER_IMAGE =
|
||||||
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||||
@@ -47,10 +46,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
|||||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||||
10,
|
10,
|
||||||
); // 10MB default
|
); // 10MB default
|
||||||
export const CREDENTIAL_PROXY_PORT = parseInt(
|
|
||||||
process.env.CREDENTIAL_PROXY_PORT || '3001',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
export const IPC_POLL_INTERVAL = 1000;
|
export const IPC_POLL_INTERVAL = 1000;
|
||||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||||
@@ -71,3 +66,9 @@ export const TRIGGER_PATTERN = new RegExp(
|
|||||||
// Uses system timezone by default
|
// Uses system timezone by default
|
||||||
export const TIMEZONE =
|
export const TIMEZONE =
|
||||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Telegram configuration
|
||||||
|
export const TELEGRAM_BOT_TOKEN =
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
|
||||||
|
export const TELEGRAM_ONLY =
|
||||||
|
(process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ vi.mock('./config.js', () => ({
|
|||||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||||
CREDENTIAL_PROXY_PORT: 3001,
|
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||||
IDLE_TIMEOUT: 1800000, // 30min
|
IDLE_TIMEOUT: 1800000, // 30min
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Container Runner for NanoClaw
|
* Container Runner for NanoClaw
|
||||||
* Spawns agent execution in containers and handles IPC
|
* Spawns agent execution in containers and handles IPC
|
||||||
*/
|
*/
|
||||||
import { ChildProcess, exec, spawn } from 'child_process';
|
import { ChildProcess, exec, execFileSync, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -10,22 +10,20 @@ import {
|
|||||||
CONTAINER_IMAGE,
|
CONTAINER_IMAGE,
|
||||||
CONTAINER_MAX_OUTPUT_SIZE,
|
CONTAINER_MAX_OUTPUT_SIZE,
|
||||||
CONTAINER_TIMEOUT,
|
CONTAINER_TIMEOUT,
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
GROUPS_DIR,
|
GROUPS_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
CONTAINER_HOST_GATEWAY,
|
|
||||||
CONTAINER_RUNTIME_BIN,
|
CONTAINER_RUNTIME_BIN,
|
||||||
hostGatewayArgs,
|
isRootlessDocker,
|
||||||
readonlyMountArgs,
|
readonlyMountArgs,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import { detectAuthMode } from './credential-proxy.js';
|
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -40,7 +38,9 @@ export interface ContainerInput {
|
|||||||
chatJid: string;
|
chatJid: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
|
ipcSuffix?: string;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
secrets?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerOutput {
|
export interface ContainerOutput {
|
||||||
@@ -59,6 +59,7 @@ interface VolumeMount {
|
|||||||
function buildVolumeMounts(
|
function buildVolumeMounts(
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
|
ipcSuffix?: string,
|
||||||
): VolumeMount[] {
|
): VolumeMount[] {
|
||||||
const mounts: VolumeMount[] = [];
|
const mounts: VolumeMount[] = [];
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
@@ -76,17 +77,6 @@ function buildVolumeMounts(
|
|||||||
readonly: true,
|
readonly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
|
||||||
// Credentials are injected by the credential proxy, never exposed to containers.
|
|
||||||
const envFile = path.join(projectRoot, '.env');
|
|
||||||
if (fs.existsSync(envFile)) {
|
|
||||||
mounts.push({
|
|
||||||
hostPath: '/dev/null',
|
|
||||||
containerPath: '/workspace/project/.env',
|
|
||||||
readonly: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main also gets its group folder as the working directory
|
// Main also gets its group folder as the working directory
|
||||||
mounts.push({
|
mounts.push({
|
||||||
hostPath: groupDir,
|
hostPath: groupDir,
|
||||||
@@ -163,9 +153,13 @@ function buildVolumeMounts(
|
|||||||
readonly: false,
|
readonly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-group IPC namespace: each group gets its own IPC directory
|
// Per-group IPC namespace: each group gets its own IPC directory.
|
||||||
// This prevents cross-group privilege escalation via IPC
|
// Task containers use a separate IPC dir (ipcSuffix='-task') to avoid
|
||||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
// cross-contamination with concurrent user message containers.
|
||||||
|
const ipcFolderName = ipcSuffix
|
||||||
|
? `${group.folder}${ipcSuffix}`
|
||||||
|
: group.folder;
|
||||||
|
const groupIpcDir = path.join(DATA_DIR, 'ipc', ipcFolderName);
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||||
@@ -212,6 +206,25 @@ function buildVolumeMounts(
|
|||||||
return mounts;
|
return mounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read allowed secrets from .env for passing to the container via stdin.
|
||||||
|
* Secrets are never written to disk or mounted as files.
|
||||||
|
*/
|
||||||
|
function readSecrets(): Record<string, string> {
|
||||||
|
return readEnvFile([
|
||||||
|
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'PLANE_API_KEY',
|
||||||
|
'PLANE_BASE_URL',
|
||||||
|
'PLANE_WORKSPACE',
|
||||||
|
'EMAIL_ADDRESS',
|
||||||
|
'EMAIL_IMAP_PASSWORD',
|
||||||
|
'EMAIL_CALDAV_PASSWORD',
|
||||||
|
'CALDAV_URL',
|
||||||
|
'COOLIFY_API_KEY',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function buildContainerArgs(
|
function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -221,26 +234,6 @@ function buildContainerArgs(
|
|||||||
// Pass host timezone so container's local time matches the user's
|
// Pass host timezone so container's local time matches the user's
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
args.push('-e', `TZ=${TIMEZONE}`);
|
||||||
|
|
||||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
|
||||||
args.push(
|
|
||||||
'-e',
|
|
||||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mirror the host's auth method with a placeholder value.
|
|
||||||
// API key mode: SDK sends x-api-key, proxy replaces with real key.
|
|
||||||
// OAuth mode: SDK exchanges placeholder token for temp API key,
|
|
||||||
// proxy injects real OAuth token on that exchange request.
|
|
||||||
const authMode = detectAuthMode();
|
|
||||||
if (authMode === 'api-key') {
|
|
||||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
|
||||||
} else {
|
|
||||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runtime-specific args for host gateway resolution
|
|
||||||
args.push(...hostGatewayArgs());
|
|
||||||
|
|
||||||
// Run as host user so bind-mounted files are accessible.
|
// Run as host user so bind-mounted files are accessible.
|
||||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
// or when getuid is unavailable (native Windows without WSL).
|
||||||
@@ -275,7 +268,25 @@ export async function runContainerAgent(
|
|||||||
const groupDir = resolveGroupFolderPath(group.folder);
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
const mounts = buildVolumeMounts(group, input.isMain);
|
const mounts = buildVolumeMounts(group, input.isMain, input.ipcSuffix);
|
||||||
|
|
||||||
|
// In rootless Docker, the container's node user (uid 1000) maps to a
|
||||||
|
// subordinate uid that can't write to host-owned directories.
|
||||||
|
// Make writable mounts world-accessible so the container user can write.
|
||||||
|
if (isRootlessDocker()) {
|
||||||
|
for (const mount of mounts) {
|
||||||
|
if (!mount.readonly) {
|
||||||
|
try {
|
||||||
|
execFileSync('chmod', ['-R', 'a+rwX', mount.hostPath], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||||
const containerArgs = buildContainerArgs(mounts, containerName);
|
const containerArgs = buildContainerArgs(mounts, containerName);
|
||||||
@@ -318,8 +329,12 @@ export async function runContainerAgent(
|
|||||||
let stdoutTruncated = false;
|
let stdoutTruncated = false;
|
||||||
let stderrTruncated = false;
|
let stderrTruncated = false;
|
||||||
|
|
||||||
|
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||||
|
input.secrets = readSecrets();
|
||||||
container.stdin.write(JSON.stringify(input));
|
container.stdin.write(JSON.stringify(input));
|
||||||
container.stdin.end();
|
container.stdin.end();
|
||||||
|
// Remove secrets from input so they don't appear in logs
|
||||||
|
delete input.secrets;
|
||||||
|
|
||||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||||
let parseBuffer = '';
|
let parseBuffer = '';
|
||||||
@@ -369,10 +384,18 @@ export async function runContainerAgent(
|
|||||||
// so idle timers start even for "silent" query completions.
|
// so idle timers start even for "silent" query completions.
|
||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
parseErrorCount++;
|
||||||
|
if (parseErrorCount <= 3) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, error: err },
|
{ group: group.name, error: err },
|
||||||
'Failed to parse streamed output chunk',
|
'Failed to parse streamed output chunk',
|
||||||
);
|
);
|
||||||
|
} else if (parseErrorCount === 4) {
|
||||||
|
logger.warn(
|
||||||
|
{ group: group.name },
|
||||||
|
'Suppressing further parse error warnings for this container run',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,6 +425,7 @@ export async function runContainerAgent(
|
|||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let hadStreamingOutput = false;
|
let hadStreamingOutput = false;
|
||||||
|
let parseErrorCount = 0;
|
||||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
||||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
// graceful _close sentinel has time to trigger before the hard kill fires.
|
||||||
|
|||||||
@@ -2,51 +2,33 @@
|
|||||||
* Container runtime abstraction for NanoClaw.
|
* Container runtime abstraction for NanoClaw.
|
||||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execFileSync, execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
/** The container runtime binary name. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
export const CONTAINER_RUNTIME_BIN = 'docker';
|
||||||
|
|
||||||
/** Hostname containers use to reach the host machine. */
|
/** Detect rootless Docker (container root maps to host user). */
|
||||||
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
|
let _rootlessDocker: boolean | undefined;
|
||||||
|
export function isRootlessDocker(): boolean {
|
||||||
/**
|
if (_rootlessDocker === undefined) {
|
||||||
* Address the credential proxy binds to.
|
try {
|
||||||
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
|
const info = execFileSync(
|
||||||
* Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
|
CONTAINER_RUNTIME_BIN,
|
||||||
* falling back to 0.0.0.0 if the interface isn't found.
|
['info', '--format', '{{.SecurityOptions}}'],
|
||||||
*/
|
{
|
||||||
export const PROXY_BIND_HOST =
|
stdio: 'pipe',
|
||||||
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
function detectProxyBindHost(): string {
|
},
|
||||||
if (os.platform() === 'darwin') return '127.0.0.1';
|
);
|
||||||
|
_rootlessDocker = info.includes('rootless');
|
||||||
// WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
|
} catch {
|
||||||
// Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
|
_rootlessDocker = false;
|
||||||
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
|
|
||||||
|
|
||||||
// Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
|
|
||||||
const ifaces = os.networkInterfaces();
|
|
||||||
const docker0 = ifaces['docker0'];
|
|
||||||
if (docker0) {
|
|
||||||
const ipv4 = docker0.find((a) => a.family === 'IPv4');
|
|
||||||
if (ipv4) return ipv4.address;
|
|
||||||
}
|
}
|
||||||
return '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CLI args needed for the container to resolve the host gateway. */
|
|
||||||
export function hostGatewayArgs(): string[] {
|
|
||||||
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
|
||||||
if (os.platform() === 'linux') {
|
|
||||||
return ['--add-host=host.docker.internal:host-gateway'];
|
|
||||||
}
|
}
|
||||||
return [];
|
return _rootlessDocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns CLI args for a readonly bind mount. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
|
|||||||
113
src/db.test.ts
113
src/db.test.ts
@@ -2,14 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
|
closeDatabase,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
setRegisteredGroup,
|
logTaskRun,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
updateTask,
|
updateTask,
|
||||||
@@ -391,94 +391,37 @@ describe('task CRUD', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- LIMIT behavior ---
|
describe('closeDatabase', () => {
|
||||||
|
it('can be called without throwing', () => {
|
||||||
describe('message query LIMIT', () => {
|
expect(() => closeDatabase()).not.toThrow();
|
||||||
beforeEach(() => {
|
|
||||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
store({
|
|
||||||
id: `lim-${i}`,
|
|
||||||
chat_jid: 'group@g.us',
|
|
||||||
sender: 'user@s.whatsapp.net',
|
|
||||||
sender_name: 'User',
|
|
||||||
content: `message ${i}`,
|
|
||||||
timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getNewMessages caps to limit and returns most recent in chronological order', () => {
|
|
||||||
const { messages, newTimestamp } = getNewMessages(
|
|
||||||
['group@g.us'],
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(3);
|
|
||||||
expect(messages[0].content).toBe('message 8');
|
|
||||||
expect(messages[2].content).toBe('message 10');
|
|
||||||
// Chronological order preserved
|
|
||||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
|
||||||
// newTimestamp reflects latest returned row
|
|
||||||
expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getMessagesSince caps to limit and returns most recent in chronological order', () => {
|
|
||||||
const messages = getMessagesSince(
|
|
||||||
'group@g.us',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(3);
|
|
||||||
expect(messages[0].content).toBe('message 8');
|
|
||||||
expect(messages[2].content).toBe('message 10');
|
|
||||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns all messages when count is under the limit', () => {
|
|
||||||
const { messages } = getNewMessages(
|
|
||||||
['group@g.us'],
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
'Andy',
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
expect(messages).toHaveLength(10);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- RegisteredGroup isMain round-trip ---
|
describe('deleteTask atomicity', () => {
|
||||||
|
it('deletes task and its logs', () => {
|
||||||
describe('registered group isMain', () => {
|
createTask({
|
||||||
it('persists isMain=true through set/get round-trip', () => {
|
id: 'task-del-1',
|
||||||
setRegisteredGroup('main@s.whatsapp.net', {
|
group_folder: 'main',
|
||||||
name: 'Main Chat',
|
chat_jid: 'jid@g.us',
|
||||||
folder: 'whatsapp_main',
|
prompt: 'test',
|
||||||
trigger: '@Andy',
|
schedule_type: 'once',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
schedule_value: '2026-01-01T00:00:00Z',
|
||||||
isMain: true,
|
context_mode: 'isolated',
|
||||||
|
next_run: '2026-01-01T00:00:00Z',
|
||||||
|
status: 'active',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
logTaskRun({
|
||||||
|
task_id: 'task-del-1',
|
||||||
|
run_at: new Date().toISOString(),
|
||||||
|
duration_ms: 100,
|
||||||
|
status: 'success',
|
||||||
|
result: 'ok',
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
deleteTask('task-del-1');
|
||||||
const group = groups['main@s.whatsapp.net'];
|
|
||||||
expect(group).toBeDefined();
|
|
||||||
expect(group.isMain).toBe(true);
|
|
||||||
expect(group.folder).toBe('whatsapp_main');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits isMain for non-main groups', () => {
|
expect(getTaskById('task-del-1')).toBeUndefined();
|
||||||
setRegisteredGroup('group@g.us', {
|
|
||||||
name: 'Family Chat',
|
|
||||||
folder: 'whatsapp_family-chat',
|
|
||||||
trigger: '@Andy',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
|
||||||
const group = groups['group@g.us'];
|
|
||||||
expect(group).toBeDefined();
|
|
||||||
expect(group.isMain).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
57
src/db.ts
57
src/db.ts
@@ -106,19 +106,6 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
|
||||||
try {
|
|
||||||
database.exec(
|
|
||||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
|
||||||
);
|
|
||||||
// Backfill: existing rows with folder = 'main' are the main group
|
|
||||||
database.exec(
|
|
||||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* column already exists */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||||
try {
|
try {
|
||||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
||||||
@@ -158,6 +145,10 @@ export function _initTestDatabase(): void {
|
|||||||
createSchema(db);
|
createSchema(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeDatabase(): void {
|
||||||
|
db?.close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store chat metadata only (no message content).
|
* Store chat metadata only (no message content).
|
||||||
* Used for all chats to enable group discovery without storing sensitive content.
|
* Used for all chats to enable group discovery without storing sensitive content.
|
||||||
@@ -276,7 +267,7 @@ export function storeMessage(msg: NewMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a message directly.
|
* Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
|
||||||
*/
|
*/
|
||||||
export function storeMessageDirect(msg: {
|
export function storeMessageDirect(msg: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -306,29 +297,24 @@ export function getNewMessages(
|
|||||||
jids: string[],
|
jids: string[],
|
||||||
lastTimestamp: string,
|
lastTimestamp: string,
|
||||||
botPrefix: string,
|
botPrefix: string,
|
||||||
limit: number = 200,
|
|
||||||
): { messages: NewMessage[]; newTimestamp: string } {
|
): { messages: NewMessage[]; newTimestamp: string } {
|
||||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||||
|
|
||||||
const placeholders = jids.map(() => '?').join(',');
|
const placeholders = jids.map(() => '?').join(',');
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter bot messages using both the is_bot_message flag AND the content
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// prefix as a backstop for messages written before the migration ran.
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||||
AND content != '' AND content IS NOT NULL
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp
|
||||||
LIMIT ?
|
|
||||||
) ORDER BY timestamp
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
|
||||||
|
|
||||||
let newTimestamp = lastTimestamp;
|
let newTimestamp = lastTimestamp;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -342,25 +328,20 @@ export function getMessagesSince(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
sinceTimestamp: string,
|
sinceTimestamp: string,
|
||||||
botPrefix: string,
|
botPrefix: string,
|
||||||
limit: number = 200,
|
|
||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter bot messages using both the is_bot_message flag AND the content
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// prefix as a backstop for messages written before the migration ran.
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE chat_jid = ? AND timestamp > ?
|
WHERE chat_jid = ? AND timestamp > ?
|
||||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||||
AND content != '' AND content IS NOT NULL
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp
|
||||||
LIMIT ?
|
|
||||||
) ORDER BY timestamp
|
|
||||||
`;
|
`;
|
||||||
return db
|
return db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTask(
|
export function createTask(
|
||||||
@@ -447,9 +428,10 @@ export function updateTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTask(id: string): void {
|
export function deleteTask(id: string): void {
|
||||||
// Delete child records first (FK constraint)
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDueTasks(): ScheduledTask[] {
|
export function getDueTasks(): ScheduledTask[] {
|
||||||
@@ -526,6 +508,10 @@ export function setSession(groupFolder: string, sessionId: string): void {
|
|||||||
).run(groupFolder, sessionId);
|
).run(groupFolder, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteSession(groupFolder: string): void {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllSessions(): Record<string, string> {
|
export function getAllSessions(): Record<string, string> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||||
@@ -553,7 +539,6 @@ export function getRegisteredGroup(
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
container_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
@@ -575,7 +560,6 @@ export function getRegisteredGroup(
|
|||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||||
isMain: row.is_main === 1 ? true : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,8 +568,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||||
}
|
}
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
jid,
|
jid,
|
||||||
group.name,
|
group.name,
|
||||||
@@ -594,7 +578,6 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
group.added_at,
|
group.added_at,
|
||||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
||||||
group.isMain ? 1 : 0,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +590,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
container_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
|
||||||
}>;
|
}>;
|
||||||
const result: Record<string, RegisteredGroup> = {};
|
const result: Record<string, RegisteredGroup> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -628,7 +610,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||||
isMain: row.is_main === 1 ? true : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -58,14 +58,13 @@ describe('escapeXml', () => {
|
|||||||
// --- formatMessages ---
|
// --- formatMessages ---
|
||||||
|
|
||||||
describe('formatMessages', () => {
|
describe('formatMessages', () => {
|
||||||
const TZ = 'UTC';
|
it('formats a single message as XML', () => {
|
||||||
|
const result = formatMessages([makeMsg()]);
|
||||||
it('formats a single message as XML with context header', () => {
|
expect(result).toBe(
|
||||||
const result = formatMessages([makeMsg()], TZ);
|
'<messages>\n' +
|
||||||
expect(result).toContain('<context timezone="UTC" />');
|
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' +
|
||||||
expect(result).toContain('<message sender="Alice"');
|
'</messages>',
|
||||||
expect(result).toContain('>hello</message>');
|
);
|
||||||
expect(result).toContain('Jan 1, 2024');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats multiple messages', () => {
|
it('formats multiple messages', () => {
|
||||||
@@ -74,16 +73,11 @@ describe('formatMessages', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
sender_name: 'Alice',
|
sender_name: 'Alice',
|
||||||
content: 'hi',
|
content: 'hi',
|
||||||
timestamp: '2024-01-01T00:00:00.000Z',
|
timestamp: 't1',
|
||||||
}),
|
|
||||||
makeMsg({
|
|
||||||
id: '2',
|
|
||||||
sender_name: 'Bob',
|
|
||||||
content: 'hey',
|
|
||||||
timestamp: '2024-01-01T01:00:00.000Z',
|
|
||||||
}),
|
}),
|
||||||
|
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
|
||||||
];
|
];
|
||||||
const result = formatMessages(msgs, TZ);
|
const result = formatMessages(msgs);
|
||||||
expect(result).toContain('sender="Alice"');
|
expect(result).toContain('sender="Alice"');
|
||||||
expect(result).toContain('sender="Bob"');
|
expect(result).toContain('sender="Bob"');
|
||||||
expect(result).toContain('>hi</message>');
|
expect(result).toContain('>hi</message>');
|
||||||
@@ -91,35 +85,22 @@ describe('formatMessages', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('escapes special characters in sender names', () => {
|
it('escapes special characters in sender names', () => {
|
||||||
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
|
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]);
|
||||||
expect(result).toContain('sender="A & B <Co>"');
|
expect(result).toContain('sender="A & B <Co>"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes special characters in content', () => {
|
it('escapes special characters in content', () => {
|
||||||
const result = formatMessages(
|
const result = formatMessages([
|
||||||
[makeMsg({ content: '<script>alert("xss")</script>' })],
|
makeMsg({ content: '<script>alert("xss")</script>' }),
|
||||||
TZ,
|
]);
|
||||||
);
|
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
'<script>alert("xss")</script>',
|
'<script>alert("xss")</script>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty array', () => {
|
it('handles empty array', () => {
|
||||||
const result = formatMessages([], TZ);
|
const result = formatMessages([]);
|
||||||
expect(result).toContain('<context timezone="UTC" />');
|
expect(result).toBe('<messages>\n\n</messages>');
|
||||||
expect(result).toContain('<messages>\n\n</messages>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts timestamps to local time for given timezone', () => {
|
|
||||||
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
|
|
||||||
const result = formatMessages(
|
|
||||||
[makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
|
|
||||||
'America/New_York',
|
|
||||||
);
|
|
||||||
expect(result).toContain('1:30');
|
|
||||||
expect(result).toContain('PM');
|
|
||||||
expect(result).toContain('<context timezone="America/New_York" />');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -243,41 +243,6 @@ describe('GroupQueue', () => {
|
|||||||
expect(processed).toContain('group3@g.us');
|
expect(processed).toContain('group3@g.us');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Running task dedup (Issue #138) ---
|
|
||||||
|
|
||||||
it('rejects duplicate enqueue of a currently-running task', async () => {
|
|
||||||
let resolveTask: () => void;
|
|
||||||
let taskCallCount = 0;
|
|
||||||
|
|
||||||
const taskFn = vi.fn(async () => {
|
|
||||||
taskCallCount++;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
resolveTask = resolve;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the task (runs immediately — slot available)
|
|
||||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
expect(taskCallCount).toBe(1);
|
|
||||||
|
|
||||||
// Scheduler poll re-discovers the same task while it's running —
|
|
||||||
// this must be silently dropped
|
|
||||||
const dupFn = vi.fn(async () => {});
|
|
||||||
queue.enqueueTask('group1@g.us', 'task-1', dupFn);
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
|
|
||||||
// Duplicate was NOT queued
|
|
||||||
expect(dupFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Complete the original task
|
|
||||||
resolveTask!();
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
|
|
||||||
// Only one execution total
|
|
||||||
expect(taskCallCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Idle preemption ---
|
// --- Idle preemption ---
|
||||||
|
|
||||||
it('does NOT preempt active container when not idle', async () => {
|
it('does NOT preempt active container when not idle', async () => {
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ interface GroupState {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
idleWaiting: boolean;
|
idleWaiting: boolean;
|
||||||
isTaskContainer: boolean;
|
isTaskContainer: boolean;
|
||||||
runningTaskId: string | null;
|
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: QueuedTask[];
|
pendingTasks: QueuedTask[];
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
containerName: string | null;
|
containerName: string | null;
|
||||||
groupFolder: string | null;
|
groupFolder: string | null;
|
||||||
|
/** IPC dir name — may differ from groupFolder for task containers (e.g. 'main-task'). */
|
||||||
|
ipcFolder: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +43,12 @@ export class GroupQueue {
|
|||||||
active: false,
|
active: false,
|
||||||
idleWaiting: false,
|
idleWaiting: false,
|
||||||
isTaskContainer: false,
|
isTaskContainer: false,
|
||||||
runningTaskId: null,
|
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
pendingTasks: [],
|
pendingTasks: [],
|
||||||
process: null,
|
process: null,
|
||||||
containerName: null,
|
containerName: null,
|
||||||
groupFolder: null,
|
groupFolder: null,
|
||||||
|
ipcFolder: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
};
|
};
|
||||||
this.groups.set(groupJid, state);
|
this.groups.set(groupJid, state);
|
||||||
@@ -92,11 +93,7 @@ export class GroupQueue {
|
|||||||
|
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
|
|
||||||
// Prevent double-queuing: check both pending and currently-running task
|
// Prevent double-queuing of the same task
|
||||||
if (state.runningTaskId === taskId) {
|
|
||||||
logger.debug({ groupJid, taskId }, 'Task already running, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
||||||
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
||||||
return;
|
return;
|
||||||
@@ -134,11 +131,13 @@ export class GroupQueue {
|
|||||||
proc: ChildProcess,
|
proc: ChildProcess,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
groupFolder?: string,
|
groupFolder?: string,
|
||||||
|
ipcFolder?: string,
|
||||||
): void {
|
): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
state.process = proc;
|
state.process = proc;
|
||||||
state.containerName = containerName;
|
state.containerName = containerName;
|
||||||
if (groupFolder) state.groupFolder = groupFolder;
|
if (groupFolder) state.groupFolder = groupFolder;
|
||||||
|
state.ipcFolder = ipcFolder ?? groupFolder ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,11 +158,11 @@ export class GroupQueue {
|
|||||||
*/
|
*/
|
||||||
sendMessage(groupJid: string, text: string): boolean {
|
sendMessage(groupJid: string, text: string): boolean {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder || state.isTaskContainer)
|
if (!state.active || !state.ipcFolder || state.isTaskContainer)
|
||||||
return false;
|
return false;
|
||||||
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input');
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
||||||
@@ -182,9 +181,9 @@ export class GroupQueue {
|
|||||||
*/
|
*/
|
||||||
closeStdin(groupJid: string): void {
|
closeStdin(groupJid: string): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder) return;
|
if (!state.active || !state.ipcFolder) return;
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.ipcFolder, 'input');
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||||
@@ -226,6 +225,7 @@ export class GroupQueue {
|
|||||||
state.process = null;
|
state.process = null;
|
||||||
state.containerName = null;
|
state.containerName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.ipcFolder = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,6 @@ export class GroupQueue {
|
|||||||
state.active = true;
|
state.active = true;
|
||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
state.isTaskContainer = true;
|
state.isTaskContainer = true;
|
||||||
state.runningTaskId = task.id;
|
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -251,10 +250,10 @@ export class GroupQueue {
|
|||||||
} finally {
|
} finally {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.isTaskContainer = false;
|
state.isTaskContainer = false;
|
||||||
state.runningTaskId = null;
|
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.containerName = null;
|
state.containerName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.ipcFolder = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
|
|||||||
200
src/index.ts
200
src/index.ts
@@ -3,18 +3,16 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
CREDENTIAL_PROXY_PORT,
|
DATA_DIR,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
TIMEZONE,
|
TELEGRAM_BOT_TOKEN,
|
||||||
|
TELEGRAM_ONLY,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { startCredentialProxy } from './credential-proxy.js';
|
import { WhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import './channels/index.js';
|
import { TelegramChannel } from './channels/telegram.js';
|
||||||
import {
|
|
||||||
getChannelFactory,
|
|
||||||
getRegisteredChannelNames,
|
|
||||||
} from './channels/registry.js';
|
|
||||||
import {
|
import {
|
||||||
ContainerOutput,
|
ContainerOutput,
|
||||||
runContainerAgent,
|
runContainerAgent,
|
||||||
@@ -22,18 +20,14 @@ import {
|
|||||||
writeTasksSnapshot,
|
writeTasksSnapshot,
|
||||||
} from './container-runner.js';
|
} from './container-runner.js';
|
||||||
import {
|
import {
|
||||||
cleanupOrphans,
|
closeDatabase,
|
||||||
ensureContainerRuntimeRunning,
|
deleteSession,
|
||||||
PROXY_BIND_HOST,
|
|
||||||
} from './container-runtime.js';
|
|
||||||
import {
|
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getAllSessions,
|
getAllSessions,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getRegisteredGroup,
|
|
||||||
getRouterState,
|
getRouterState,
|
||||||
initDatabase,
|
initDatabase,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
@@ -43,18 +37,16 @@ import {
|
|||||||
storeMessage,
|
storeMessage,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
|
||||||
import { startIpcWatcher } from './ipc.js';
|
import { startIpcWatcher } from './ipc.js';
|
||||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||||
import {
|
|
||||||
isSenderAllowed,
|
|
||||||
isTriggerAllowed,
|
|
||||||
loadSenderAllowlist,
|
|
||||||
shouldDropMessage,
|
|
||||||
} from './sender-allowlist.js';
|
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
ensureContainerRuntimeRunning,
|
||||||
|
cleanupOrphans,
|
||||||
|
} from './container-runtime.js';
|
||||||
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility during refactor
|
// Re-export for backwards compatibility during refactor
|
||||||
export { escapeXml, formatMessages } from './router.js';
|
export { escapeXml, formatMessages } from './router.js';
|
||||||
@@ -65,6 +57,7 @@ let registeredGroups: Record<string, RegisteredGroup> = {};
|
|||||||
let lastAgentTimestamp: Record<string, string> = {};
|
let lastAgentTimestamp: Record<string, string> = {};
|
||||||
let messageLoopRunning = false;
|
let messageLoopRunning = false;
|
||||||
|
|
||||||
|
let whatsapp: WhatsAppChannel;
|
||||||
const channels: Channel[] = [];
|
const channels: Channel[] = [];
|
||||||
const queue = new GroupQueue();
|
const queue = new GroupQueue();
|
||||||
|
|
||||||
@@ -91,22 +84,24 @@ function saveState(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||||
let groupDir: string;
|
// Validate + persist first (setRegisteredGroup throws on invalid folder)
|
||||||
try {
|
|
||||||
groupDir = resolveGroupFolderPath(group.folder);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ jid, folder: group.folder, err },
|
|
||||||
'Rejecting group registration with invalid folder',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
registeredGroups[jid] = group;
|
|
||||||
setRegisteredGroup(jid, group);
|
setRegisteredGroup(jid, group);
|
||||||
|
|
||||||
// Create group folder
|
// Create group folder after DB write so a mkdir failure doesn't leave
|
||||||
|
// a DB record with no matching directory
|
||||||
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
|
try {
|
||||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{ jid, folder: group.folder, err },
|
||||||
|
'Failed to create group directory',
|
||||||
|
);
|
||||||
|
// Don't throw — DB record is written; directory can be created on next restart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-memory state after I/O succeeds
|
||||||
|
registeredGroups[jid] = group;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jid, name: group.name, folder: group.folder },
|
{ jid, name: group.name, folder: group.folder },
|
||||||
@@ -153,7 +148,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMainGroup = group.isMain === true;
|
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
|
||||||
|
|
||||||
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
||||||
const missedMessages = getMessagesSince(
|
const missedMessages = getMessagesSince(
|
||||||
@@ -166,16 +161,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
|
|
||||||
// For non-main groups, check if trigger is required and present
|
// For non-main groups, check if trigger is required and present
|
||||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||||
const allowlistCfg = loadSenderAllowlist();
|
const hasTrigger = missedMessages.some((m) =>
|
||||||
const hasTrigger = missedMessages.some(
|
TRIGGER_PATTERN.test(m.content.trim()),
|
||||||
(m) =>
|
|
||||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
|
||||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
||||||
);
|
);
|
||||||
if (!hasTrigger) return true;
|
if (!hasTrigger) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = formatMessages(missedMessages, TIMEZONE);
|
const prompt = formatMessages(missedMessages);
|
||||||
|
|
||||||
// Advance cursor so the piping path in startMessageLoop won't re-fetch
|
// Advance cursor so the piping path in startMessageLoop won't re-fetch
|
||||||
// these messages. Save the old cursor so we can roll back on error.
|
// these messages. Save the old cursor so we can roll back on error.
|
||||||
@@ -225,10 +217,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
resetIdleTimer();
|
resetIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
queue.notifyIdle(chatJid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 'error') {
|
if (result.status === 'error') {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
}
|
}
|
||||||
@@ -266,7 +254,7 @@ async function runAgent(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||||
): Promise<'success' | 'error'> {
|
): Promise<'success' | 'error'> {
|
||||||
const isMain = group.isMain === true;
|
const isMain = group.folder === MAIN_GROUP_FOLDER;
|
||||||
const sessionId = sessions[group.folder];
|
const sessionId = sessions[group.folder];
|
||||||
|
|
||||||
// Update tasks snapshot for container to read (filtered by group)
|
// Update tasks snapshot for container to read (filtered by group)
|
||||||
@@ -314,7 +302,6 @@ async function runAgent(
|
|||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
chatJid,
|
chatJid,
|
||||||
isMain,
|
isMain,
|
||||||
assistantName: ASSISTANT_NAME,
|
|
||||||
},
|
},
|
||||||
(proc, containerName) =>
|
(proc, containerName) =>
|
||||||
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||||
@@ -350,6 +337,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
|
|
||||||
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
|
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
|
||||||
|
|
||||||
|
let consecutiveErrors = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const jids = Object.keys(registeredGroups);
|
const jids = Object.keys(registeredGroups);
|
||||||
@@ -387,19 +375,15 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMainGroup = group.isMain === true;
|
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
|
||||||
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||||
|
|
||||||
// For non-main groups, only act on trigger messages.
|
// For non-main groups, only act on trigger messages.
|
||||||
// Non-trigger messages accumulate in DB and get pulled as
|
// Non-trigger messages accumulate in DB and get pulled as
|
||||||
// context when a trigger eventually arrives.
|
// context when a trigger eventually arrives.
|
||||||
if (needsTrigger) {
|
if (needsTrigger) {
|
||||||
const allowlistCfg = loadSenderAllowlist();
|
const hasTrigger = groupMessages.some((m) =>
|
||||||
const hasTrigger = groupMessages.some(
|
TRIGGER_PATTERN.test(m.content.trim()),
|
||||||
(m) =>
|
|
||||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
|
||||||
(m.is_from_me ||
|
|
||||||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
||||||
);
|
);
|
||||||
if (!hasTrigger) continue;
|
if (!hasTrigger) continue;
|
||||||
}
|
}
|
||||||
@@ -413,7 +397,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
);
|
);
|
||||||
const messagesToSend =
|
const messagesToSend =
|
||||||
allPending.length > 0 ? allPending : groupMessages;
|
allPending.length > 0 ? allPending : groupMessages;
|
||||||
const formatted = formatMessages(messagesToSend, TIMEZONE);
|
const formatted = formatMessages(messagesToSend);
|
||||||
|
|
||||||
if (queue.sendMessage(chatJid, formatted)) {
|
if (queue.sendMessage(chatJid, formatted)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -435,8 +419,17 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
consecutiveErrors = 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
consecutiveErrors++;
|
||||||
|
if (consecutiveErrors === 1) {
|
||||||
logger.error({ err }, 'Error in message loop');
|
logger.error({ err }, 'Error in message loop');
|
||||||
|
} else if (consecutiveErrors % 10 === 0) {
|
||||||
|
logger.error(
|
||||||
|
{ err, consecutiveErrors },
|
||||||
|
'Message loop error persisting — check DB and disk',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
@@ -460,29 +453,19 @@ function recoverPendingMessages(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureContainerSystemRunning(): void {
|
async function main(): Promise<void> {
|
||||||
ensureContainerRuntimeRunning();
|
ensureContainerRuntimeRunning();
|
||||||
cleanupOrphans();
|
cleanupOrphans();
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
ensureContainerSystemRunning();
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
|
||||||
const proxyServer = await startCredentialProxy(
|
|
||||||
CREDENTIAL_PROXY_PORT,
|
|
||||||
PROXY_BIND_HOST,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
proxyServer.close();
|
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
|
closeDatabase();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
@@ -490,25 +473,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Channel callbacks (shared by all channels)
|
// Channel callbacks (shared by all channels)
|
||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
|
||||||
// Sender allowlist drop mode: discard messages from denied senders before storing
|
|
||||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
|
||||||
const cfg = loadSenderAllowlist();
|
|
||||||
if (
|
|
||||||
shouldDropMessage(chatJid, cfg) &&
|
|
||||||
!isSenderAllowed(chatJid, msg.sender, cfg)
|
|
||||||
) {
|
|
||||||
if (cfg.logDenied) {
|
|
||||||
logger.debug(
|
|
||||||
{ chatJid, sender: msg.sender },
|
|
||||||
'sender-allowlist: dropping message (drop mode)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storeMessage(msg);
|
|
||||||
},
|
|
||||||
onChatMetadata: (
|
onChatMetadata: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
@@ -517,27 +482,30 @@ async function main(): Promise<void> {
|
|||||||
isGroup?: boolean,
|
isGroup?: boolean,
|
||||||
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
|
clearSession: (chatJid: string) => {
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (group) {
|
||||||
|
delete sessions[group.folder];
|
||||||
|
deleteSession(group.folder);
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, folder: group.folder },
|
||||||
|
'Session cleared via /reset',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and connect all registered channels.
|
// Create and connect channels
|
||||||
// Each channel self-registers via the barrel import above.
|
if (!TELEGRAM_ONLY) {
|
||||||
// Factories return null when credentials are missing, so unconfigured channels are skipped.
|
whatsapp = new WhatsAppChannel(channelOpts);
|
||||||
for (const channelName of getRegisteredChannelNames()) {
|
channels.push(whatsapp);
|
||||||
const factory = getChannelFactory(channelName)!;
|
await whatsapp.connect();
|
||||||
const channel = factory(channelOpts);
|
|
||||||
if (!channel) {
|
|
||||||
logger.warn(
|
|
||||||
{ channel: channelName },
|
|
||||||
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
channels.push(channel);
|
|
||||||
await channel.connect();
|
if (TELEGRAM_BOT_TOKEN) {
|
||||||
}
|
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
|
||||||
if (channels.length === 0) {
|
channels.push(telegram);
|
||||||
logger.fatal('No channels connected');
|
await telegram.connect();
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start subsystems (independently of connection handler)
|
// Start subsystems (independently of connection handler)
|
||||||
@@ -545,8 +513,14 @@ async function main(): Promise<void> {
|
|||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
getSessions: () => sessions,
|
getSessions: () => sessions,
|
||||||
queue,
|
queue,
|
||||||
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
onProcess: (queueKey, proc, containerName, groupFolder, ipcFolder) =>
|
||||||
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
queue.registerProcess(
|
||||||
|
queueKey,
|
||||||
|
proc,
|
||||||
|
containerName,
|
||||||
|
groupFolder,
|
||||||
|
ipcFolder,
|
||||||
|
),
|
||||||
sendMessage: async (jid, rawText) => {
|
sendMessage: async (jid, rawText) => {
|
||||||
const channel = findChannel(channels, jid);
|
const channel = findChannel(channels, jid);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
@@ -565,23 +539,15 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
registerGroup,
|
registerGroup,
|
||||||
syncGroups: async (force: boolean) => {
|
syncGroupMetadata: (force) =>
|
||||||
await Promise.all(
|
whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
|
||||||
channels
|
|
||||||
.filter((ch) => ch.syncGroups)
|
|
||||||
.map((ch) => ch.syncGroups!(force)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getAvailableGroups,
|
getAvailableGroups,
|
||||||
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
||||||
writeGroupsSnapshot(gf, im, ag, rj),
|
writeGroupsSnapshot(gf, im, ag, rj),
|
||||||
});
|
});
|
||||||
queue.setProcessMessagesFn(processGroupMessages);
|
queue.setProcessMessagesFn(processGroupMessages);
|
||||||
recoverPendingMessages();
|
recoverPendingMessages();
|
||||||
startMessageLoop().catch((err) => {
|
startMessageLoop();
|
||||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: only run when executed directly, not when imported by tests
|
// Guard: only run when executed directly, not when imported by tests
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ import { RegisteredGroup } from './types.js';
|
|||||||
// Set up registered groups used across tests
|
// Set up registered groups used across tests
|
||||||
const MAIN_GROUP: RegisteredGroup = {
|
const MAIN_GROUP: RegisteredGroup = {
|
||||||
name: 'Main',
|
name: 'Main',
|
||||||
folder: 'whatsapp_main',
|
folder: 'main',
|
||||||
trigger: 'always',
|
trigger: 'always',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
isMain: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const OTHER_GROUP: RegisteredGroup = {
|
const OTHER_GROUP: RegisteredGroup = {
|
||||||
@@ -59,7 +58,7 @@ beforeEach(() => {
|
|||||||
setRegisteredGroup(jid, group);
|
setRegisteredGroup(jid, group);
|
||||||
// Mock the fs.mkdirSync that registerGroup does
|
// Mock the fs.mkdirSync that registerGroup does
|
||||||
},
|
},
|
||||||
syncGroups: async () => {},
|
syncGroupMetadata: async () => {},
|
||||||
getAvailableGroups: () => [],
|
getAvailableGroups: () => [],
|
||||||
writeGroupsSnapshot: () => {},
|
writeGroupsSnapshot: () => {},
|
||||||
};
|
};
|
||||||
@@ -74,10 +73,10 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'do something',
|
prompt: 'do something',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -94,7 +93,7 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'self task',
|
prompt: 'self task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
@@ -113,7 +112,7 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'unauthorized',
|
prompt: 'unauthorized',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'main@g.us',
|
targetJid: 'main@g.us',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
@@ -131,10 +130,10 @@ describe('schedule_task authorization', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'no target',
|
prompt: 'no target',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'unknown@g.us',
|
targetJid: 'unknown@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -150,11 +149,11 @@ describe('pause_task authorization', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTask({
|
createTask({
|
||||||
id: 'task-main',
|
id: 'task-main',
|
||||||
group_folder: 'whatsapp_main',
|
group_folder: 'main',
|
||||||
chat_jid: 'main@g.us',
|
chat_jid: 'main@g.us',
|
||||||
prompt: 'main task',
|
prompt: 'main task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -166,7 +165,7 @@ describe('pause_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'other task',
|
prompt: 'other task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -177,7 +176,7 @@ describe('pause_task authorization', () => {
|
|||||||
it('main group can pause any task', async () => {
|
it('main group can pause any task', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'pause_task', taskId: 'task-other' },
|
{ type: 'pause_task', taskId: 'task-other' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -215,7 +214,7 @@ describe('resume_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'paused task',
|
prompt: 'paused task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: '2025-06-01T00:00:00.000Z',
|
next_run: '2025-06-01T00:00:00.000Z',
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
@@ -226,7 +225,7 @@ describe('resume_task authorization', () => {
|
|||||||
it('main group can resume any task', async () => {
|
it('main group can resume any task', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'resume_task', taskId: 'task-paused' },
|
{ type: 'resume_task', taskId: 'task-paused' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -264,7 +263,7 @@ describe('cancel_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'cancel me',
|
prompt: 'cancel me',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -273,7 +272,7 @@ describe('cancel_task authorization', () => {
|
|||||||
|
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -287,7 +286,7 @@ describe('cancel_task authorization', () => {
|
|||||||
chat_jid: 'other@g.us',
|
chat_jid: 'other@g.us',
|
||||||
prompt: 'my task',
|
prompt: 'my task',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -306,11 +305,11 @@ describe('cancel_task authorization', () => {
|
|||||||
it('non-main group cannot cancel another groups task', async () => {
|
it('non-main group cannot cancel another groups task', async () => {
|
||||||
createTask({
|
createTask({
|
||||||
id: 'task-foreign',
|
id: 'task-foreign',
|
||||||
group_folder: 'whatsapp_main',
|
group_folder: 'main',
|
||||||
chat_jid: 'main@g.us',
|
chat_jid: 'main@g.us',
|
||||||
prompt: 'not yours',
|
prompt: 'not yours',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
next_run: null,
|
next_run: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -357,7 +356,7 @@ describe('register_group authorization', () => {
|
|||||||
folder: '../../outside',
|
folder: '../../outside',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -398,12 +397,8 @@ describe('IPC message authorization', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('main group can send to any group', () => {
|
it('main group can send to any group', () => {
|
||||||
expect(
|
expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true);
|
||||||
isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups),
|
expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('non-main group can send to its own chat', () => {
|
it('non-main group can send to its own chat', () => {
|
||||||
@@ -429,9 +424,9 @@ describe('IPC message authorization', () => {
|
|||||||
|
|
||||||
it('main group can send to unregistered JID', () => {
|
it('main group can send to unregistered JID', () => {
|
||||||
// Main is always authorized regardless of target
|
// Main is always authorized regardless of target
|
||||||
expect(
|
expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(
|
||||||
isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +442,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '0 9 * * *', // every day at 9am
|
schedule_value: '0 9 * * *', // every day at 9am
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -471,7 +466,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'not a cron',
|
schedule_value: 'not a cron',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -490,7 +485,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '3600000', // 1 hour
|
schedule_value: '3600000', // 1 hour
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -513,7 +508,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'abc',
|
schedule_value: 'abc',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -530,7 +525,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: '0',
|
schedule_value: '0',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -547,7 +542,7 @@ describe('schedule_task schedule types', () => {
|
|||||||
schedule_value: 'not-a-date',
|
schedule_value: 'not-a-date',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -565,11 +560,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'group context',
|
prompt: 'group context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'group',
|
context_mode: 'group',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -584,11 +579,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'isolated context',
|
prompt: 'isolated context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'isolated',
|
context_mode: 'isolated',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -603,11 +598,11 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'bad context',
|
prompt: 'bad context',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
context_mode: 'bogus' as any,
|
context_mode: 'bogus' as any,
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -622,10 +617,10 @@ describe('schedule_task context_mode', () => {
|
|||||||
type: 'schedule_task',
|
type: 'schedule_task',
|
||||||
prompt: 'no context mode',
|
prompt: 'no context mode',
|
||||||
schedule_type: 'once',
|
schedule_type: 'once',
|
||||||
schedule_value: '2025-06-01T00:00:00',
|
schedule_value: '2025-06-01T00:00:00.000Z',
|
||||||
targetJid: 'other@g.us',
|
targetJid: 'other@g.us',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -647,7 +642,7 @@ describe('register_group success', () => {
|
|||||||
folder: 'new-group',
|
folder: 'new-group',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
@@ -668,7 +663,7 @@ describe('register_group success', () => {
|
|||||||
name: 'Partial',
|
name: 'Partial',
|
||||||
// missing folder and trigger
|
// missing folder and trigger
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'main',
|
||||||
true,
|
true,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|||||||
133
src/ipc.ts
133
src/ipc.ts
@@ -3,7 +3,12 @@ import path from 'path';
|
|||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
|
||||||
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
import {
|
||||||
|
DATA_DIR,
|
||||||
|
IPC_POLL_INTERVAL,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
|
TIMEZONE,
|
||||||
|
} from './config.js';
|
||||||
import { AvailableGroup } from './container-runner.js';
|
import { AvailableGroup } from './container-runner.js';
|
||||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -14,7 +19,7 @@ export interface IpcDeps {
|
|||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
||||||
syncGroups: (force: boolean) => Promise<void>;
|
syncGroupMetadata: (force: boolean) => Promise<void>;
|
||||||
getAvailableGroups: () => AvailableGroup[];
|
getAvailableGroups: () => AvailableGroup[];
|
||||||
writeGroupsSnapshot: (
|
writeGroupsSnapshot: (
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
@@ -52,14 +57,13 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
|
|
||||||
const registeredGroups = deps.registeredGroups();
|
const registeredGroups = deps.registeredGroups();
|
||||||
|
|
||||||
// Build folder→isMain lookup from registered groups
|
|
||||||
const folderIsMain = new Map<string, boolean>();
|
|
||||||
for (const group of Object.values(registeredGroups)) {
|
|
||||||
if (group.isMain) folderIsMain.set(group.folder, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sourceGroup of groupFolders) {
|
for (const sourceGroup of groupFolders) {
|
||||||
const isMain = folderIsMain.get(sourceGroup) === true;
|
// Strip '-task' suffix so task containers (e.g. 'main-task') are authorized
|
||||||
|
// as their base group ('main') for all IPC operations.
|
||||||
|
const baseFolder = sourceGroup.endsWith('-task')
|
||||||
|
? sourceGroup.slice(0, -5)
|
||||||
|
: sourceGroup;
|
||||||
|
const isMain = baseFolder === MAIN_GROUP_FOLDER;
|
||||||
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
||||||
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
||||||
|
|
||||||
@@ -78,7 +82,9 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
const targetGroup = registeredGroups[data.chatJid];
|
const targetGroup = registeredGroups[data.chatJid];
|
||||||
if (
|
if (
|
||||||
isMain ||
|
isMain ||
|
||||||
(targetGroup && targetGroup.folder === sourceGroup)
|
(targetGroup &&
|
||||||
|
(targetGroup.folder === sourceGroup ||
|
||||||
|
targetGroup.folder === baseFolder))
|
||||||
) {
|
) {
|
||||||
await deps.sendMessage(data.chatJid, data.text);
|
await deps.sendMessage(data.chatJid, data.text);
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -177,6 +183,9 @@ export async function processTaskIpc(
|
|||||||
deps: IpcDeps,
|
deps: IpcDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const registeredGroups = deps.registeredGroups();
|
const registeredGroups = deps.registeredGroups();
|
||||||
|
const baseFolder = sourceGroup.endsWith('-task')
|
||||||
|
? sourceGroup.slice(0, -5)
|
||||||
|
: sourceGroup;
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'schedule_task':
|
case 'schedule_task':
|
||||||
@@ -201,7 +210,11 @@ export async function processTaskIpc(
|
|||||||
const targetFolder = targetGroupEntry.folder;
|
const targetFolder = targetGroupEntry.folder;
|
||||||
|
|
||||||
// Authorization: non-main groups can only schedule for themselves
|
// Authorization: non-main groups can only schedule for themselves
|
||||||
if (!isMain && targetFolder !== sourceGroup) {
|
if (
|
||||||
|
!isMain &&
|
||||||
|
targetFolder !== sourceGroup &&
|
||||||
|
targetFolder !== baseFolder
|
||||||
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ sourceGroup, targetFolder },
|
{ sourceGroup, targetFolder },
|
||||||
'Unauthorized schedule_task attempt blocked',
|
'Unauthorized schedule_task attempt blocked',
|
||||||
@@ -236,20 +249,18 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
nextRun = new Date(Date.now() + ms).toISOString();
|
nextRun = new Date(Date.now() + ms).toISOString();
|
||||||
} else if (scheduleType === 'once') {
|
} else if (scheduleType === 'once') {
|
||||||
const date = new Date(data.schedule_value);
|
const scheduled = new Date(data.schedule_value);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(scheduled.getTime())) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ scheduleValue: data.schedule_value },
|
{ scheduleValue: data.schedule_value },
|
||||||
'Invalid timestamp',
|
'Invalid timestamp',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
nextRun = date.toISOString();
|
nextRun = scheduled.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId =
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
data.taskId ||
|
|
||||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const contextMode =
|
const contextMode =
|
||||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||||
? data.context_mode
|
? data.context_mode
|
||||||
@@ -276,7 +287,12 @@ export async function processTaskIpc(
|
|||||||
case 'pause_task':
|
case 'pause_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
updateTask(data.taskId, { status: 'paused' });
|
updateTask(data.taskId, { status: 'paused' });
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -294,7 +310,12 @@ export async function processTaskIpc(
|
|||||||
case 'resume_task':
|
case 'resume_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
updateTask(data.taskId, { status: 'active' });
|
updateTask(data.taskId, { status: 'active' });
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -312,7 +333,12 @@ export async function processTaskIpc(
|
|||||||
case 'cancel_task':
|
case 'cancel_task':
|
||||||
if (data.taskId) {
|
if (data.taskId) {
|
||||||
const task = getTaskById(data.taskId);
|
const task = getTaskById(data.taskId);
|
||||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
if (
|
||||||
|
task &&
|
||||||
|
(isMain ||
|
||||||
|
task.group_folder === sourceGroup ||
|
||||||
|
task.group_folder === baseFolder)
|
||||||
|
) {
|
||||||
deleteTask(data.taskId);
|
deleteTask(data.taskId);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: data.taskId, sourceGroup },
|
{ taskId: data.taskId, sourceGroup },
|
||||||
@@ -327,70 +353,6 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'update_task':
|
|
||||||
if (data.taskId) {
|
|
||||||
const task = getTaskById(data.taskId);
|
|
||||||
if (!task) {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, sourceGroup },
|
|
||||||
'Task not found for update',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!isMain && task.group_folder !== sourceGroup) {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, sourceGroup },
|
|
||||||
'Unauthorized task update attempt',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Parameters<typeof updateTask>[1] = {};
|
|
||||||
if (data.prompt !== undefined) updates.prompt = data.prompt;
|
|
||||||
if (data.schedule_type !== undefined)
|
|
||||||
updates.schedule_type = data.schedule_type as
|
|
||||||
| 'cron'
|
|
||||||
| 'interval'
|
|
||||||
| 'once';
|
|
||||||
if (data.schedule_value !== undefined)
|
|
||||||
updates.schedule_value = data.schedule_value;
|
|
||||||
|
|
||||||
// Recompute next_run if schedule changed
|
|
||||||
if (data.schedule_type || data.schedule_value) {
|
|
||||||
const updatedTask = {
|
|
||||||
...task,
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
if (updatedTask.schedule_type === 'cron') {
|
|
||||||
try {
|
|
||||||
const interval = CronExpressionParser.parse(
|
|
||||||
updatedTask.schedule_value,
|
|
||||||
{ tz: TIMEZONE },
|
|
||||||
);
|
|
||||||
updates.next_run = interval.next().toISOString();
|
|
||||||
} catch {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: data.taskId, value: updatedTask.schedule_value },
|
|
||||||
'Invalid cron in task update',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (updatedTask.schedule_type === 'interval') {
|
|
||||||
const ms = parseInt(updatedTask.schedule_value, 10);
|
|
||||||
if (!isNaN(ms) && ms > 0) {
|
|
||||||
updates.next_run = new Date(Date.now() + ms).toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTask(data.taskId, updates);
|
|
||||||
logger.info(
|
|
||||||
{ taskId: data.taskId, sourceGroup, updates },
|
|
||||||
'Task updated via IPC',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'refresh_groups':
|
case 'refresh_groups':
|
||||||
// Only main group can request a refresh
|
// Only main group can request a refresh
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
@@ -398,7 +360,7 @@ export async function processTaskIpc(
|
|||||||
{ sourceGroup },
|
{ sourceGroup },
|
||||||
'Group metadata refresh requested via IPC',
|
'Group metadata refresh requested via IPC',
|
||||||
);
|
);
|
||||||
await deps.syncGroups(true);
|
await deps.syncGroupMetadata(true);
|
||||||
// Write updated snapshot immediately
|
// Write updated snapshot immediately
|
||||||
const availableGroups = deps.getAvailableGroups();
|
const availableGroups = deps.getAvailableGroups();
|
||||||
deps.writeGroupsSnapshot(
|
deps.writeGroupsSnapshot(
|
||||||
@@ -432,7 +394,6 @@ export async function processTaskIpc(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Defense in depth: agent cannot set isMain via IPC
|
|
||||||
deps.registerGroup(data.jid, {
|
deps.registerGroup(data.jid, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
folder: data.folder,
|
folder: data.folder,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Channel, NewMessage } from './types.js';
|
import { Channel, NewMessage } from './types.js';
|
||||||
import { formatLocalTime } from './timezone.js';
|
|
||||||
|
|
||||||
export function escapeXml(s: string): string {
|
export function escapeXml(s: string): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -10,18 +9,12 @@ export function escapeXml(s: string): string {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMessages(
|
export function formatMessages(messages: NewMessage[]): string {
|
||||||
messages: NewMessage[],
|
const lines = messages.map(
|
||||||
timezone: string,
|
(m) =>
|
||||||
): string {
|
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
|
||||||
const lines = messages.map((m) => {
|
);
|
||||||
const displayTime = formatLocalTime(m.timestamp, timezone);
|
return `<messages>\n${lines.join('\n')}\n</messages>`;
|
||||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
|
||||||
|
|
||||||
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripInternalTags(text: string): string {
|
export function stripInternalTags(text: string): string {
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ describe('JID ownership patterns', () => {
|
|||||||
const jid = '12345678@s.whatsapp.net';
|
const jid = '12345678@s.whatsapp.net';
|
||||||
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Telegram JID: starts with tg:', () => {
|
||||||
|
const jid = 'tg:123456789';
|
||||||
|
expect(jid.startsWith('tg:')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Telegram group JID: starts with tg: and has negative ID', () => {
|
||||||
|
const jid = 'tg:-1001234567890';
|
||||||
|
expect(jid.startsWith('tg:')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- getAvailableGroups ---
|
// --- getAvailableGroups ---
|
||||||
@@ -167,4 +177,103 @@ describe('getAvailableGroups', () => {
|
|||||||
const groups = getAvailableGroups();
|
const groups = getAvailableGroups();
|
||||||
expect(groups).toHaveLength(0);
|
expect(groups).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes Telegram chat JIDs', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100200300',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'Telegram Chat',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'user@s.whatsapp.net',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'User DM',
|
||||||
|
'whatsapp',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].jid).toBe('tg:100200300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Telegram group JIDs with negative IDs', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:-1001234567890',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'TG Group',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].jid).toBe('tg:-1001234567890');
|
||||||
|
expect(groups[0].name).toBe('TG Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks registered Telegram chats correctly', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100200300',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'TG Registered',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:999999',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'TG Unregistered',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_setRegisteredGroups({
|
||||||
|
'tg:100200300': {
|
||||||
|
name: 'TG Registered',
|
||||||
|
folder: 'tg-registered',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
const tgReg = groups.find((g) => g.jid === 'tg:100200300');
|
||||||
|
const tgUnreg = groups.find((g) => g.jid === 'tg:999999');
|
||||||
|
|
||||||
|
expect(tgReg?.isRegistered).toBe(true);
|
||||||
|
expect(tgUnreg?.isRegistered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes WhatsApp and Telegram chats ordered by activity', () => {
|
||||||
|
storeChatMetadata(
|
||||||
|
'wa@g.us',
|
||||||
|
'2024-01-01T00:00:01.000Z',
|
||||||
|
'WhatsApp',
|
||||||
|
'whatsapp',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'tg:100',
|
||||||
|
'2024-01-01T00:00:03.000Z',
|
||||||
|
'Telegram',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
storeChatMetadata(
|
||||||
|
'wa2@g.us',
|
||||||
|
'2024-01-01T00:00:02.000Z',
|
||||||
|
'WhatsApp 2',
|
||||||
|
'whatsapp',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = getAvailableGroups();
|
||||||
|
expect(groups).toHaveLength(3);
|
||||||
|
expect(groups[0].jid).toBe('tg:100');
|
||||||
|
expect(groups[1].jid).toBe('wa2@g.us');
|
||||||
|
expect(groups[2].jid).toBe('wa@g.us');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||||
import {
|
import {
|
||||||
_resetSchedulerLoopForTests,
|
_resetSchedulerLoopForTests,
|
||||||
computeNextRun,
|
|
||||||
startSchedulerLoop,
|
startSchedulerLoop,
|
||||||
} from './task-scheduler.js';
|
} from './task-scheduler.js';
|
||||||
|
|
||||||
@@ -51,79 +50,4 @@ describe('task scheduler', () => {
|
|||||||
const task = getTaskById('task-invalid-folder');
|
const task = getTaskById('task-invalid-folder');
|
||||||
expect(task?.status).toBe('paused');
|
expect(task?.status).toBe('paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
|
||||||
const task = {
|
|
||||||
id: 'drift-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'interval' as const,
|
|
||||||
schedule_value: '60000', // 1 minute
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: scheduledTime,
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
expect(nextRun).not.toBeNull();
|
|
||||||
|
|
||||||
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
|
|
||||||
const expected = new Date(scheduledTime).getTime() + 60000;
|
|
||||||
expect(new Date(nextRun!).getTime()).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computeNextRun returns null for once-tasks', () => {
|
|
||||||
const task = {
|
|
||||||
id: 'once-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'once' as const,
|
|
||||||
schedule_value: '2026-01-01T00:00:00.000Z',
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: new Date(Date.now() - 1000).toISOString(),
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(computeNextRun(task)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computeNextRun skips missed intervals without infinite loop', () => {
|
|
||||||
// Task was due 10 intervals ago (missed)
|
|
||||||
const ms = 60000;
|
|
||||||
const missedBy = ms * 10;
|
|
||||||
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
|
|
||||||
|
|
||||||
const task = {
|
|
||||||
id: 'skip-test',
|
|
||||||
group_folder: 'test',
|
|
||||||
chat_jid: 'test@g.us',
|
|
||||||
prompt: 'test',
|
|
||||||
schedule_type: 'interval' as const,
|
|
||||||
schedule_value: String(ms),
|
|
||||||
context_mode: 'isolated' as const,
|
|
||||||
next_run: scheduledTime,
|
|
||||||
last_run: null,
|
|
||||||
last_result: null,
|
|
||||||
status: 'active' as const,
|
|
||||||
created_at: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
expect(nextRun).not.toBeNull();
|
|
||||||
// Must be in the future
|
|
||||||
expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
|
|
||||||
// Must be aligned to the original schedule grid
|
|
||||||
const offset =
|
|
||||||
(new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
|
|
||||||
expect(offset).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process';
|
|||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
|
import {
|
||||||
|
ASSISTANT_NAME,
|
||||||
|
MAIN_GROUP_FOLDER,
|
||||||
|
SCHEDULER_POLL_INTERVAL,
|
||||||
|
TIMEZONE,
|
||||||
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
ContainerOutput,
|
ContainerOutput,
|
||||||
runContainerAgent,
|
runContainerAgent,
|
||||||
@@ -21,56 +26,16 @@ import { resolveGroupFolderPath } from './group-folder.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the next run time for a recurring task, anchored to the
|
|
||||||
* task's scheduled time rather than Date.now() to prevent cumulative
|
|
||||||
* drift on interval-based tasks.
|
|
||||||
*
|
|
||||||
* Co-authored-by: @community-pr-601
|
|
||||||
*/
|
|
||||||
export function computeNextRun(task: ScheduledTask): string | null {
|
|
||||||
if (task.schedule_type === 'once') return null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (task.schedule_type === 'cron') {
|
|
||||||
const interval = CronExpressionParser.parse(task.schedule_value, {
|
|
||||||
tz: TIMEZONE,
|
|
||||||
});
|
|
||||||
return interval.next().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.schedule_type === 'interval') {
|
|
||||||
const ms = parseInt(task.schedule_value, 10);
|
|
||||||
if (!ms || ms <= 0) {
|
|
||||||
// Guard against malformed interval that would cause an infinite loop
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: task.id, value: task.schedule_value },
|
|
||||||
'Invalid interval value',
|
|
||||||
);
|
|
||||||
return new Date(now + 60_000).toISOString();
|
|
||||||
}
|
|
||||||
// Anchor to the scheduled time, not now, to prevent drift.
|
|
||||||
// Skip past any missed intervals so we always land in the future.
|
|
||||||
let next = new Date(task.next_run!).getTime() + ms;
|
|
||||||
while (next <= now) {
|
|
||||||
next += ms;
|
|
||||||
}
|
|
||||||
return new Date(next).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulerDependencies {
|
export interface SchedulerDependencies {
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
onProcess: (
|
onProcess: (
|
||||||
groupJid: string,
|
queueKey: string,
|
||||||
proc: ChildProcess,
|
proc: ChildProcess,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
|
ipcFolder?: string,
|
||||||
) => void;
|
) => void;
|
||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -130,7 +95,7 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update tasks snapshot for container to read (filtered by group)
|
// Update tasks snapshot for container to read (filtered by group)
|
||||||
const isMain = group.isMain === true;
|
const isMain = task.group_folder === MAIN_GROUP_FOLDER;
|
||||||
const tasks = getAllTasks();
|
const tasks = getAllTasks();
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
@@ -160,11 +125,15 @@ async function runTask(
|
|||||||
const TASK_CLOSE_DELAY_MS = 10000;
|
const TASK_CLOSE_DELAY_MS = 10000;
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Tasks run in a separate queue slot (task:chatJid) and separate IPC dir
|
||||||
|
// (groupFolder-task/) so they don't block or contaminate user message containers.
|
||||||
|
const taskQueueKey = `task:${task.chat_jid}`;
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
if (closeTimer) return; // already scheduled
|
if (closeTimer) return; // already scheduled
|
||||||
closeTimer = setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
||||||
deps.queue.closeStdin(task.chat_jid);
|
deps.queue.closeStdin(taskQueueKey);
|
||||||
}, TASK_CLOSE_DELAY_MS);
|
}, TASK_CLOSE_DELAY_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,20 +147,29 @@ async function runTask(
|
|||||||
chatJid: task.chat_jid,
|
chatJid: task.chat_jid,
|
||||||
isMain,
|
isMain,
|
||||||
isScheduledTask: true,
|
isScheduledTask: true,
|
||||||
|
ipcSuffix: '-task',
|
||||||
assistantName: ASSISTANT_NAME,
|
assistantName: ASSISTANT_NAME,
|
||||||
},
|
},
|
||||||
(proc, containerName) =>
|
(proc, containerName) =>
|
||||||
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
deps.onProcess(
|
||||||
|
taskQueueKey,
|
||||||
|
proc,
|
||||||
|
containerName,
|
||||||
|
task.group_folder,
|
||||||
|
`${task.group_folder}-task`,
|
||||||
|
),
|
||||||
async (streamedOutput: ContainerOutput) => {
|
async (streamedOutput: ContainerOutput) => {
|
||||||
if (streamedOutput.result) {
|
if (streamedOutput.result) {
|
||||||
result = streamedOutput.result;
|
result = streamedOutput.result;
|
||||||
// Forward result to user (sendMessage handles formatting)
|
// Forward result to user (sendMessage handles formatting)
|
||||||
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
||||||
scheduleClose();
|
|
||||||
}
|
}
|
||||||
if (streamedOutput.status === 'success') {
|
if (streamedOutput.status === 'success') {
|
||||||
deps.queue.notifyIdle(task.chat_jid);
|
// Close task containers on any successful output, not just when
|
||||||
scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
|
// result has text. Tasks that send output via MCP return result=null
|
||||||
|
// but are still done — without this they'd hang until hard timeout.
|
||||||
|
scheduleClose();
|
||||||
|
deps.queue.notifyIdle(taskQueueKey);
|
||||||
}
|
}
|
||||||
if (streamedOutput.status === 'error') {
|
if (streamedOutput.status === 'error') {
|
||||||
error = streamedOutput.error || 'Unknown error';
|
error = streamedOutput.error || 'Unknown error';
|
||||||
@@ -204,7 +182,7 @@ async function runTask(
|
|||||||
if (output.status === 'error') {
|
if (output.status === 'error') {
|
||||||
error = output.error || 'Unknown error';
|
error = output.error || 'Unknown error';
|
||||||
} else if (output.result) {
|
} else if (output.result) {
|
||||||
// Result was already forwarded to the user via the streaming callback above
|
// Messages are sent via MCP tool (IPC), result text is just logged
|
||||||
result = output.result;
|
result = output.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +207,18 @@ async function runTask(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
let nextRun: string | null = null;
|
||||||
|
if (task.schedule_type === 'cron') {
|
||||||
|
const interval = CronExpressionParser.parse(task.schedule_value, {
|
||||||
|
tz: TIMEZONE,
|
||||||
|
});
|
||||||
|
nextRun = interval.next().toISOString();
|
||||||
|
} else if (task.schedule_type === 'interval') {
|
||||||
|
const ms = parseInt(task.schedule_value, 10);
|
||||||
|
nextRun = new Date(Date.now() + ms).toISOString();
|
||||||
|
}
|
||||||
|
// 'once' tasks have no next run
|
||||||
|
|
||||||
const resultSummary = error
|
const resultSummary = error
|
||||||
? `Error: ${error}`
|
? `Error: ${error}`
|
||||||
: result
|
: result
|
||||||
@@ -262,7 +251,23 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () =>
|
// Advance next_run immediately to prevent re-queuing the same task
|
||||||
|
// while it's still running (task takes longer than the poll interval).
|
||||||
|
if (currentTask.schedule_type === 'cron') {
|
||||||
|
const interval = CronExpressionParser.parse(
|
||||||
|
currentTask.schedule_value,
|
||||||
|
{ tz: TIMEZONE },
|
||||||
|
);
|
||||||
|
const nextRun = interval.next().toISOString();
|
||||||
|
updateTaskAfterRun(currentTask.id, nextRun, '');
|
||||||
|
logger.debug(
|
||||||
|
{ taskId: currentTask.id, nextRun },
|
||||||
|
'Advanced next_run before task start',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskQueueKey = `task:${currentTask.chat_jid}`;
|
||||||
|
deps.queue.enqueueTask(taskQueueKey, currentTask.id, () =>
|
||||||
runTask(currentTask, deps),
|
runTask(currentTask, deps),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/transcription.ts
Normal file
112
src/transcription.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { downloadMediaMessage } from '@whiskeysockets/baileys';
|
||||||
|
import { WAMessage, WASocket } from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
interface TranscriptionConfig {
|
||||||
|
model: string;
|
||||||
|
enabled: boolean;
|
||||||
|
fallbackMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: TranscriptionConfig = {
|
||||||
|
model: 'gpt-4o-mini-transcribe',
|
||||||
|
enabled: true,
|
||||||
|
fallbackMessage: '[Voice Message - transcription unavailable]',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function transcribeWithOpenAI(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
config: TranscriptionConfig,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const env = readEnvFile(['OPENAI_API_KEY']);
|
||||||
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
logger.warn('OPENAI_API_KEY not set in .env');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openaiModule = await import('openai');
|
||||||
|
const OpenAI = openaiModule.default;
|
||||||
|
const toFile = openaiModule.toFile;
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey });
|
||||||
|
|
||||||
|
const file = await toFile(audioBuffer, 'voice.ogg', {
|
||||||
|
type: 'audio/ogg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcription = await openai.audio.transcriptions.create({
|
||||||
|
file: file,
|
||||||
|
model: config.model,
|
||||||
|
response_format: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When response_format is 'text', the API returns a plain string
|
||||||
|
return transcription as unknown as string;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'OpenAI transcription failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeBuffer(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const config = DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = await transcribeWithOpenAI(audioBuffer, config);
|
||||||
|
return transcript ? transcript.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeAudioMessage(
|
||||||
|
msg: WAMessage,
|
||||||
|
sock: WASocket,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const config = DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = (await downloadMediaMessage(
|
||||||
|
msg,
|
||||||
|
'buffer',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
logger: console as any,
|
||||||
|
reuploadRequest: sock.updateMediaMessage,
|
||||||
|
},
|
||||||
|
)) as Buffer;
|
||||||
|
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
logger.error('Failed to download audio message');
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ bytes: buffer.length }, 'Downloaded audio message');
|
||||||
|
|
||||||
|
const transcript = await transcribeWithOpenAI(buffer, config);
|
||||||
|
|
||||||
|
if (!transcript) {
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript.trim();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Transcription error');
|
||||||
|
return config.fallbackMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVoiceMessage(msg: WAMessage): boolean {
|
||||||
|
return msg.message?.audioMessage?.ptt === true;
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ export interface RegisteredGroup {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
containerConfig?: ContainerConfig;
|
containerConfig?: ContainerConfig;
|
||||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
||||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewMessage {
|
export interface NewMessage {
|
||||||
@@ -88,8 +87,6 @@ export interface Channel {
|
|||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
// Optional: typing indicator. Channels that support it implement it.
|
// Optional: typing indicator. Channels that support it implement it.
|
||||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||||
// Optional: sync group/chat names from the platform.
|
|
||||||
syncGroups?(force: boolean): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback type that channels use to deliver inbound messages
|
// Callback type that channels use to deliver inbound messages
|
||||||
@@ -97,7 +94,7 @@ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
|
|||||||
|
|
||||||
// Callback for chat metadata discovery.
|
// Callback for chat metadata discovery.
|
||||||
// name is optional — channels that deliver names inline (Telegram) pass it here;
|
// name is optional — channels that deliver names inline (Telegram) pass it here;
|
||||||
// channels that sync names separately (via syncGroups) omit it.
|
// channels that sync names separately (WhatsApp syncGroupMetadata) omit it.
|
||||||
export type OnChatMetadata = (
|
export type OnChatMetadata = (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
|
|||||||
180
src/whatsapp-auth.ts
Normal file
180
src/whatsapp-auth.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* WhatsApp Authentication Script
|
||||||
|
*
|
||||||
|
* Run this during setup to authenticate with WhatsApp.
|
||||||
|
* Displays QR code, waits for scan, saves credentials, then exits.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx src/whatsapp-auth.ts
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import pino from 'pino';
|
||||||
|
import qrcode from 'qrcode-terminal';
|
||||||
|
import readline from 'readline';
|
||||||
|
|
||||||
|
import makeWASocket, {
|
||||||
|
Browsers,
|
||||||
|
DisconnectReason,
|
||||||
|
fetchLatestWaWebVersion,
|
||||||
|
makeCacheableSignalKeyStore,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
} from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
|
const AUTH_DIR = './store/auth';
|
||||||
|
const QR_FILE = './store/qr-data.txt';
|
||||||
|
const STATUS_FILE = './store/auth-status.txt';
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: 'warn', // Quiet logging - only show errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for --pairing-code flag and phone number
|
||||||
|
const usePairingCode = process.argv.includes('--pairing-code');
|
||||||
|
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone');
|
||||||
|
|
||||||
|
function askQuestion(prompt: string): Promise<string> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSocket(
|
||||||
|
phoneNumber?: string,
|
||||||
|
isReconnect = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||||
|
|
||||||
|
if (state.creds.registered && !isReconnect) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'already_authenticated');
|
||||||
|
console.log('✓ Already authenticated with WhatsApp');
|
||||||
|
console.log(
|
||||||
|
' To re-authenticate, delete the store/auth folder and run again.',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
'Failed to fetch latest WA Web version, using default',
|
||||||
|
);
|
||||||
|
return { version: undefined };
|
||||||
|
});
|
||||||
|
const sock = makeWASocket({
|
||||||
|
version,
|
||||||
|
auth: {
|
||||||
|
creds: state.creds,
|
||||||
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
|
},
|
||||||
|
printQRInTerminal: false,
|
||||||
|
logger,
|
||||||
|
browser: Browsers.macOS('Chrome'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usePairingCode && phoneNumber && !state.creds.me) {
|
||||||
|
// Request pairing code after a short delay for connection to initialize
|
||||||
|
// Only on first connect (not reconnect after 515)
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const code = await sock.requestPairingCode(phoneNumber!);
|
||||||
|
console.log(`\n🔗 Your pairing code: ${code}\n`);
|
||||||
|
console.log(' 1. Open WhatsApp on your phone');
|
||||||
|
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
|
||||||
|
console.log(' 3. Tap "Link with phone number instead"');
|
||||||
|
console.log(` 4. Enter this code: ${code}\n`);
|
||||||
|
fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to request pairing code:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.ev.on('connection.update', (update) => {
|
||||||
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
|
if (qr) {
|
||||||
|
// Write raw QR data to file so the setup skill can render it
|
||||||
|
fs.writeFileSync(QR_FILE, qr);
|
||||||
|
console.log('Scan this QR code with WhatsApp:\n');
|
||||||
|
console.log(' 1. Open WhatsApp on your phone');
|
||||||
|
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
|
||||||
|
console.log(' 3. Point your camera at the QR code below\n');
|
||||||
|
qrcode.generate(qr, { small: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'close') {
|
||||||
|
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
|
||||||
|
|
||||||
|
if (reason === DisconnectReason.loggedOut) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'failed:logged_out');
|
||||||
|
console.log('\n✗ Logged out. Delete store/auth and try again.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (reason === DisconnectReason.timedOut) {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout');
|
||||||
|
console.log('\n✗ QR code timed out. Please try again.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (reason === 515) {
|
||||||
|
// 515 = stream error, often happens after pairing succeeds but before
|
||||||
|
// registration completes. Reconnect to finish the handshake.
|
||||||
|
console.log('\n⟳ Stream error (515) after pairing — reconnecting...');
|
||||||
|
connectSocket(phoneNumber, true);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`);
|
||||||
|
console.log('\n✗ Connection failed. Please try again.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'open') {
|
||||||
|
fs.writeFileSync(STATUS_FILE, 'authenticated');
|
||||||
|
// Clean up QR file now that we're connected
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(QR_FILE);
|
||||||
|
} catch {}
|
||||||
|
console.log('\n✓ Successfully authenticated with WhatsApp!');
|
||||||
|
console.log(' Credentials saved to store/auth/');
|
||||||
|
console.log(' You can now start the NanoClaw service.\n');
|
||||||
|
|
||||||
|
// Give it a moment to save credentials, then exit
|
||||||
|
setTimeout(() => process.exit(0), 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.ev.on('creds.update', saveCreds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(): Promise<void> {
|
||||||
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Clean up any stale QR/status files from previous runs
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(QR_FILE);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(STATUS_FILE);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let phoneNumber = phoneArg;
|
||||||
|
if (usePairingCode && !phoneNumber) {
|
||||||
|
phoneNumber = await askQuestion(
|
||||||
|
'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Starting WhatsApp authentication...\n');
|
||||||
|
|
||||||
|
await connectSocket(phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate().catch((err) => {
|
||||||
|
console.error('Authentication failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user