Initial commit: NanoClaw - Personal Claude assistant via WhatsApp
A minimal Node.js application that connects Claude Agent SDK to WhatsApp using baileys. Features per-group memory via CLAUDE.md files, session continuity, scheduled tasks, and Gmail integration via MCP. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
106
.claude/skills/customize/SKILL.md
Normal file
106
.claude/skills/customize/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: customize
|
||||
description: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels (Telegram, Slack, email input), change triggers, add integrations, modify the router, or make any other customizations. This is an interactive skill that asks questions to understand what the user wants.
|
||||
---
|
||||
|
||||
# NanoClaw Customization
|
||||
|
||||
This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand the request** - Ask clarifying questions
|
||||
2. **Plan the changes** - Identify files to modify
|
||||
3. **Implement** - Make changes directly to the code
|
||||
4. **Test guidance** - Tell user how to verify
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/config.py` | Assistant name, trigger pattern, settings |
|
||||
| `src/router.py` | Message routing, polling, agent invocation |
|
||||
| `src/scheduler_worker.py` | Scheduled task execution |
|
||||
| `src/commands.py` | Command handlers |
|
||||
| `.mcp.json` | MCP server configuration |
|
||||
| `groups/CLAUDE.md` | Global memory/persona |
|
||||
|
||||
## Common Customization Patterns
|
||||
|
||||
### Adding a New Input Channel (e.g., Telegram, Slack, Email)
|
||||
|
||||
Questions to ask:
|
||||
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
|
||||
- Same trigger word or different?
|
||||
- Same memory hierarchy or separate?
|
||||
- Should messages from this channel go to existing groups or new ones?
|
||||
|
||||
Implementation pattern:
|
||||
1. Find/add MCP server for the channel to `.mcp.json`
|
||||
2. Add polling function in `router.py` (similar to `get_new_messages()`)
|
||||
3. Add to main loop to poll both sources
|
||||
4. Ensure responses route back to correct channel
|
||||
|
||||
### Adding a New MCP Integration
|
||||
|
||||
Questions to ask:
|
||||
- What service? (Calendar, Notion, database, etc.)
|
||||
- What operations needed? (read, write, both)
|
||||
- Which groups should have access?
|
||||
|
||||
Implementation:
|
||||
1. Add MCP server config to `.mcp.json`
|
||||
2. Add tools to `allowed_tools` in `router.py`
|
||||
3. Document in `groups/CLAUDE.md`
|
||||
|
||||
### Changing Assistant Behavior
|
||||
|
||||
Questions to ask:
|
||||
- What aspect? (name, trigger, persona, response style)
|
||||
- Apply to all groups or specific ones?
|
||||
|
||||
Simple changes → edit `src/config.py`
|
||||
Persona changes → edit `groups/CLAUDE.md`
|
||||
Per-group behavior → edit specific group's `CLAUDE.md`
|
||||
|
||||
### Adding New Commands
|
||||
|
||||
Questions to ask:
|
||||
- What should the command do?
|
||||
- Available in all groups or main only?
|
||||
- Does it need new MCP tools?
|
||||
|
||||
Implementation:
|
||||
1. Add handler function in `src/commands.py`
|
||||
2. Claude will recognize natural language and call the function
|
||||
|
||||
### Changing Deployment
|
||||
|
||||
Questions to ask:
|
||||
- Target platform? (Linux server, Docker, different Mac)
|
||||
- Service manager? (systemd, Docker, supervisord)
|
||||
|
||||
Implementation:
|
||||
1. Create appropriate service files
|
||||
2. Update paths in config
|
||||
3. Provide setup instructions
|
||||
|
||||
## After Changes
|
||||
|
||||
Always tell the user:
|
||||
```bash
|
||||
# Restart to apply changes
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.router.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.router.plist
|
||||
```
|
||||
|
||||
## Example Interaction
|
||||
|
||||
User: "Add Telegram as an input channel"
|
||||
|
||||
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
|
||||
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
|
||||
3. Find Telegram MCP (e.g., telegram-mcp)
|
||||
4. Add polling for Telegram in router.py
|
||||
5. Update .mcp.json
|
||||
6. Tell user how to authenticate and test
|
||||
186
.claude/skills/setup/SKILL.md
Normal file
186
.claude/skills/setup/SKILL.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
name: setup
|
||||
description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate WhatsApp/Gmail, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
|
||||
---
|
||||
|
||||
# NanoClaw Setup
|
||||
|
||||
**IMPORTANT**: Run all commands automatically. Only pause for user action when physical interaction is required (scanning QR codes). Give clear instructions for exactly what the user needs to do.
|
||||
|
||||
## 1. Check Prerequisites
|
||||
|
||||
Run these checks. Install any that are missing:
|
||||
|
||||
```bash
|
||||
python3 --version # Need 3.10+
|
||||
node --version # Need 18+
|
||||
uv --version
|
||||
```
|
||||
|
||||
If missing, install automatically:
|
||||
- **uv**: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- **node**: `brew install node`
|
||||
- **python**: `brew install python@3.10`
|
||||
|
||||
## 2. Install Dependencies
|
||||
|
||||
Run all of these automatically:
|
||||
|
||||
```bash
|
||||
# Python dependencies
|
||||
uv venv && source .venv/bin/activate && uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
```bash
|
||||
# WhatsApp bridge dependencies
|
||||
cd bridge && npm install
|
||||
```
|
||||
|
||||
```bash
|
||||
# Create logs directory
|
||||
mkdir -p logs
|
||||
```
|
||||
|
||||
## 3. WhatsApp Authentication
|
||||
|
||||
**USER ACTION REQUIRED**
|
||||
|
||||
Run the bridge in background and monitor for connection:
|
||||
|
||||
```bash
|
||||
cd bridge && node bridge.js > /tmp/bridge_output.log 2>&1 &
|
||||
BRIDGE_PID=$!
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
> A QR code will appear below. On your phone:
|
||||
> 1. Open WhatsApp
|
||||
> 2. Tap **Settings → Linked Devices → Link a Device**
|
||||
> 3. Scan the QR code
|
||||
|
||||
Then poll for either QR code or successful connection (check every 2 seconds for up to 3 minutes):
|
||||
|
||||
```bash
|
||||
cat /tmp/bridge_output.log # Look for QR code or "Connected to WhatsApp!"
|
||||
```
|
||||
|
||||
When you see "Connected to WhatsApp!" in the output, stop the bridge:
|
||||
```bash
|
||||
kill $BRIDGE_PID
|
||||
```
|
||||
|
||||
Session persists until logged out from WhatsApp.
|
||||
|
||||
## 4. Gmail Authentication (Optional)
|
||||
|
||||
**Skip this step** unless user specifically needs Gmail integration. It requires Google Cloud Platform OAuth credentials setup.
|
||||
|
||||
If needed, user must first:
|
||||
1. Create a GCP project
|
||||
2. Enable Gmail API
|
||||
3. Create OAuth 2.0 credentials
|
||||
4. Download credentials to `~/.gmail-mcp/gcp-oauth.keys.json`
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
npx -y @gongrzhe/server-gmail-autoauth-mcp
|
||||
```
|
||||
|
||||
## 5. Register Main Channel
|
||||
|
||||
Ask the user:
|
||||
> Do you want to use a **personal chat** (message yourself) or a **WhatsApp group** as your main channel?
|
||||
|
||||
For personal chat:
|
||||
> Send a test message to yourself in WhatsApp. Tell me when done.
|
||||
|
||||
For group:
|
||||
> Send a message in the WhatsApp group you want to use. Tell me when done.
|
||||
|
||||
After user confirms, find the JID:
|
||||
|
||||
```bash
|
||||
# For personal chat
|
||||
sqlite3 bridge/store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid NOT LIKE '%@g.us' ORDER BY rowid DESC LIMIT 5"
|
||||
|
||||
# For group
|
||||
sqlite3 bridge/store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid LIKE '%@g.us' ORDER BY rowid DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Read the assistant name from `src/config.py` (look for `ASSISTANT_NAME = "..."`).
|
||||
|
||||
Then update `data/registered_groups.json`:
|
||||
```json
|
||||
{
|
||||
"THE_JID_HERE": {
|
||||
"name": "main",
|
||||
"folder": "main",
|
||||
"trigger": "@AssistantName",
|
||||
"added_at": "CURRENT_TIMESTAMP_ISO"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Configure launchd
|
||||
|
||||
First, detect the actual paths:
|
||||
|
||||
```bash
|
||||
which node # Get actual node path (may be nvm, homebrew, etc.)
|
||||
```
|
||||
|
||||
Create plist files directly in `~/Library/LaunchAgents/` with:
|
||||
|
||||
**com.nanoclaw.bridge.plist:**
|
||||
- ProgramArguments: `[actual_node_path, /Users/.../nanoclaw/bridge/bridge.js]`
|
||||
- WorkingDirectory: `/Users/.../nanoclaw/bridge`
|
||||
- StandardOutPath/StandardErrorPath: `/Users/.../nanoclaw/logs/bridge.log` and `bridge.error.log`
|
||||
|
||||
**com.nanoclaw.router.plist:**
|
||||
- ProgramArguments: `[/Users/.../nanoclaw/.venv/bin/python, -u, /Users/.../nanoclaw/src/router.py]`
|
||||
- The `-u` flag is required for unbuffered output (so logs appear immediately)
|
||||
- WorkingDirectory: `/Users/.../nanoclaw`
|
||||
- EnvironmentVariables:
|
||||
- `PATH`: `/Users/USERNAME/.local/bin:/usr/local/bin:/usr/bin:/bin` (must include path to `claude` CLI)
|
||||
- `HOME`: `/Users/USERNAME` (required for Claude CLI to find its config)
|
||||
- StandardOutPath/StandardErrorPath: `/Users/.../nanoclaw/logs/router.log` and `router.error.log`
|
||||
|
||||
**NOTE**: Do NOT set ANTHROPIC_API_KEY - the Claude CLI handles its own authentication.
|
||||
|
||||
Then load the services:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.bridge.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.router.plist
|
||||
```
|
||||
|
||||
Verify they're running:
|
||||
```bash
|
||||
launchctl list | grep nanoclaw
|
||||
```
|
||||
|
||||
## 7. Test
|
||||
|
||||
Wait a few seconds for services to start, then tell the user:
|
||||
> Send `@AssistantName hello` in your registered chat/group.
|
||||
|
||||
Check `logs/router.log` for activity:
|
||||
```bash
|
||||
tail -f logs/router.log
|
||||
```
|
||||
|
||||
If there are issues, also check:
|
||||
- `logs/router.error.log`
|
||||
- `logs/bridge.log`
|
||||
- `logs/bridge.error.log`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Command failed with exit code 1"** - Usually means the Claude CLI isn't in PATH. Verify PATH in the router plist includes the directory containing `claude` (typically `~/.local/bin`).
|
||||
|
||||
**Messages received but no WhatsApp response** - Check that the bridge HTTP server is running:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3141/send -X POST -H "Content-Type: application/json" -d '{"jid":"test","message":"test"}'
|
||||
```
|
||||
Should return an error about invalid JID (not connection refused).
|
||||
|
||||
**Router not processing messages** - Check the trigger pattern matches. Messages must start with the trigger (e.g., `@Andy hello`).
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Local data & auth
|
||||
store/
|
||||
data/
|
||||
logs/
|
||||
|
||||
# Secrets
|
||||
*.keys.json
|
||||
.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
12
.mcp.json
Normal file
12
.mcp.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gmail": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"]
|
||||
},
|
||||
"scheduler": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "schedule-task-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
CLAUDE.md
Normal file
36
CLAUDE.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant via WhatsApp.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/index.ts` - Main application (WhatsApp + routing + agent)
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `.mcp.json` - MCP server configuration (gmail, scheduler)
|
||||
- `groups/CLAUDE.md` - Global memory
|
||||
- `groups/{name}/CLAUDE.md` - Per-group memory
|
||||
|
||||
## Configuration
|
||||
|
||||
Set environment variable `ASSISTANT_NAME` to change the trigger (default: "Andy").
|
||||
|
||||
Or edit the CONFIG object in `src/index.ts`.
|
||||
|
||||
## Skills
|
||||
|
||||
- `/setup` - Install dependencies, authenticate, start services
|
||||
- `/customize` - Modify behavior
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
WhatsApp (baileys) ─┬─> SQLite (messages.db)
|
||||
│ ↓
|
||||
│ Polling loop
|
||||
│ ↓
|
||||
│ Claude Agent SDK
|
||||
│ ↓
|
||||
└─< Send response
|
||||
```
|
||||
|
||||
Single Node.js process handles everything.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gavriel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant via WhatsApp.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourname/nanoclaw.git
|
||||
cd nanoclaw
|
||||
claude
|
||||
# Run: /setup
|
||||
```
|
||||
|
||||
Claude Code handles installation, authentication, and service setup.
|
||||
|
||||
## Features
|
||||
|
||||
- **WhatsApp I/O**: Message Claude from your phone
|
||||
- **Persistent memory**: Per-group conversation context
|
||||
- **Global memory**: Shared context across all groups
|
||||
- **Email tools**: Read/send via Gmail (optional)
|
||||
- **Scheduled tasks**: Recurring reminders and jobs
|
||||
- **Web access**: Search and fetch content
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
@Andy what's the weather in NYC?
|
||||
@Andy summarize my unread emails
|
||||
@Andy remind me every Monday at 9am to check metrics
|
||||
/clear
|
||||
```
|
||||
|
||||
From main channel:
|
||||
```
|
||||
@Andy add group "Family Chat"
|
||||
@Andy list groups
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS (or Linux)
|
||||
- Node.js 18+
|
||||
- Claude Code CLI (authenticated)
|
||||
|
||||
## Manual Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
Run Claude Code and ask to:
|
||||
- "Change trigger to @Bot"
|
||||
- "Make responses more concise"
|
||||
|
||||
Or use `/customize`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Node.js process using:
|
||||
- `@whiskeysockets/baileys` - WhatsApp Web API
|
||||
- `@anthropic-ai/claude-agent-sdk` - Claude Agent SDK
|
||||
- `better-sqlite3` - Message storage
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
109
REQUIREMENTS.md
Normal file
109
REQUIREMENTS.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# NanoClaw Requirements
|
||||
|
||||
Original requirements and design decisions from the project creator.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
A personal Claude assistant accessible via WhatsApp, with minimal custom code.
|
||||
|
||||
**Core components:**
|
||||
- **Claude Agent SDK** as the core agent
|
||||
- **WhatsApp** as the primary I/O channel
|
||||
- **Persistent memory** per conversation and globally
|
||||
- **Scheduled tasks** that run Claude and can message back
|
||||
- **Web access** for search and browsing
|
||||
|
||||
**Design philosophy:**
|
||||
- Leverage existing tools (WhatsApp connector, Claude Agent SDK, MCP servers)
|
||||
- Minimal glue code
|
||||
- File-based systems where possible (CLAUDE.md for memory, folders for groups)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Message Routing
|
||||
- A router listens to WhatsApp and routes messages based on configuration
|
||||
- Only messages from registered groups are processed
|
||||
- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var
|
||||
- Unregistered groups are ignored completely
|
||||
|
||||
### Memory System
|
||||
- **Per-group memory**: Each group has a folder with its own `CLAUDE.md`
|
||||
- **Global memory**: Root `CLAUDE.md` is read by all groups, but only writable from "main" (self-chat)
|
||||
- **Files**: Groups can create/read files in their folder and reference them
|
||||
- Agent runs in the group's folder, automatically inherits both CLAUDE.md files
|
||||
|
||||
### Session Management
|
||||
- Each group maintains a conversation session (via Claude Agent SDK)
|
||||
- `/clear` command resets the session but keeps memory files
|
||||
- Old session IDs are archived to a file
|
||||
|
||||
### Scheduled Tasks
|
||||
- Users can ask Claude to schedule cron jobs from any group
|
||||
- Tasks run in the context of the group that created them (with that group's memory)
|
||||
- Task output is logged to the group's folder
|
||||
|
||||
### Group Management
|
||||
- New groups are added explicitly via the main channel
|
||||
- Groups are identified by human-readable name when possible
|
||||
- Each group gets a dedicated folder
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### WhatsApp
|
||||
- Using baileys library for WhatsApp Web connection
|
||||
- Messages stored in SQLite, polled by router
|
||||
- QR code authentication during setup
|
||||
|
||||
### Email (Gmail)
|
||||
- Read-only MCP integration (can read and send, but not an I/O channel)
|
||||
- Optional, enabled during setup
|
||||
|
||||
### Scheduler
|
||||
- MCP server for creating/managing scheduled tasks
|
||||
- Tasks execute Claude Agent SDK in group context
|
||||
|
||||
### Web Access
|
||||
- Built-in WebSearch and WebFetch tools
|
||||
- Standard Claude Agent SDK capabilities
|
||||
|
||||
---
|
||||
|
||||
## Setup & Customization
|
||||
|
||||
### Philosophy
|
||||
- Minimal configuration files
|
||||
- Setup and customization done via Claude Code
|
||||
- Users clone the repo and run Claude Code to configure
|
||||
- Each user gets a custom setup matching their exact needs
|
||||
|
||||
### Skills
|
||||
- `/setup` - Install dependencies, authenticate WhatsApp/Gmail, configure scheduler, start services
|
||||
- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes)
|
||||
|
||||
### Deployment
|
||||
- Runs on local Mac via launchd
|
||||
- Single Node.js process handles everything
|
||||
|
||||
---
|
||||
|
||||
## Personal Configuration (Reference)
|
||||
|
||||
These are the creator's settings, stored here for reference:
|
||||
|
||||
- **Trigger**: `@Andy` (case insensitive)
|
||||
- **Response prefix**: `Andy:`
|
||||
- **Persona**: Default Claude (no custom personality)
|
||||
- **Gmail**: Enabled
|
||||
- **Main channel**: Self-chat (messaging yourself in WhatsApp)
|
||||
|
||||
---
|
||||
|
||||
## Project Name
|
||||
|
||||
**NanoClaw** - A reference to Clawdbot (now OpenClaw).
|
||||
484
SPEC.md
Normal file
484
SPEC.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# NanoClaw Specification
|
||||
|
||||
A personal Claude assistant accessible via WhatsApp, with persistent memory per conversation, scheduled tasks, and email integration.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture](#architecture)
|
||||
2. [Folder Structure](#folder-structure)
|
||||
3. [Configuration](#configuration)
|
||||
4. [Memory System](#memory-system)
|
||||
5. [Session Management](#session-management)
|
||||
6. [Message Flow](#message-flow)
|
||||
7. [Commands](#commands)
|
||||
8. [Scheduled Tasks](#scheduled-tasks)
|
||||
9. [MCP Servers](#mcp-servers)
|
||||
10. [Deployment](#deployment)
|
||||
11. [Security Considerations](#security-considerations)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ NanoClaw │
|
||||
│ (Single Node.js Process) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ WhatsApp │────────────────────▶│ SQLite Database │ │
|
||||
│ │ (baileys) │◀────────────────────│ (messages.db) │ │
|
||||
│ └──────────────┘ store/send └─────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ MESSAGE LOOP │ │
|
||||
│ │ • Polls SQLite for new messages every 2 seconds │ │
|
||||
│ │ • Filters: only registered groups, only trigger word │ │
|
||||
│ │ • Loads session ID for conversation continuity │ │
|
||||
│ │ • Invokes Claude Agent SDK in the group's directory │ │
|
||||
│ │ • Sends response back to WhatsApp │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CLAUDE AGENT SDK │ │
|
||||
│ │ │ │
|
||||
│ │ Working directory: groups/{group-name}/ │ │
|
||||
│ │ Context loaded: │ │
|
||||
│ │ • ../CLAUDE.md (global memory) │ │
|
||||
│ │ • ./CLAUDE.md (group-specific memory) │ │
|
||||
│ │ │ │
|
||||
│ │ Available MCP Servers: │ │
|
||||
│ │ • gmail-mcp (read/send email) │ │
|
||||
│ │ • schedule-task-mcp (create cron jobs) │ │
|
||||
│ │ │ │
|
||||
│ │ Built-in Tools: │ │
|
||||
│ │ • WebSearch, WebFetch (internet access) │ │
|
||||
│ │ • Read, Write, Edit (file operations in group folder) │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|-----------|------------|---------|
|
||||
| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
|
||||
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
|
||||
| Agent | @anthropic-ai/claude-agent-sdk | Run Claude with tools and MCP servers |
|
||||
| Runtime | Node.js 18+ | Single unified process |
|
||||
|
||||
---
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
nanoclaw/
|
||||
├── CLAUDE.md # Project context for Claude Code
|
||||
├── SPEC.md # This specification document
|
||||
├── README.md # User documentation
|
||||
├── package.json # Node.js dependencies
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── .mcp.json # MCP server configuration (reference)
|
||||
├── .gitignore
|
||||
│
|
||||
├── src/
|
||||
│ └── index.ts # Main application (WhatsApp + routing + agent)
|
||||
│
|
||||
├── dist/ # Compiled JavaScript (gitignored)
|
||||
│ └── index.js
|
||||
│
|
||||
├── .claude/
|
||||
│ └── skills/
|
||||
│ ├── setup/
|
||||
│ │ └── SKILL.md # /setup skill
|
||||
│ └── customize/
|
||||
│ └── SKILL.md # /customize skill
|
||||
│
|
||||
├── groups/
|
||||
│ ├── CLAUDE.md # Global memory (all groups read this)
|
||||
│ ├── main/ # Self-chat (main control channel)
|
||||
│ │ ├── CLAUDE.md # Main channel memory
|
||||
│ │ └── logs/ # Task execution logs
|
||||
│ └── {Group Name}/ # Per-group folders (created on registration)
|
||||
│ ├── CLAUDE.md # Group-specific memory
|
||||
│ ├── logs/ # Task logs for this group
|
||||
│ └── *.md # Files created by the agent
|
||||
│
|
||||
├── store/ # Local data (gitignored)
|
||||
│ ├── auth/ # WhatsApp authentication state
|
||||
│ └── messages.db # SQLite message database
|
||||
│
|
||||
├── data/ # Application state (gitignored)
|
||||
│ ├── sessions.json # Active session IDs per group
|
||||
│ ├── archived_sessions.json # Old sessions after /clear
|
||||
│ ├── registered_groups.json # Group JID → folder mapping
|
||||
│ └── router_state.json # Last processed timestamp
|
||||
│
|
||||
├── logs/ # Runtime logs (gitignored)
|
||||
│ ├── nanoclaw.log # stdout
|
||||
│ └── nanoclaw.error.log # stderr
|
||||
│
|
||||
└── launchd/
|
||||
└── com.nanoclaw.plist # macOS service configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is done via environment variables and the CONFIG object in `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
const CONFIG = {
|
||||
assistantName: process.env.ASSISTANT_NAME || 'Andy',
|
||||
pollInterval: 2000, // ms
|
||||
storeDir: './store',
|
||||
groupsDir: './groups',
|
||||
dataDir: './data',
|
||||
};
|
||||
```
|
||||
|
||||
### Changing the Assistant Name
|
||||
|
||||
Set the `ASSISTANT_NAME` environment variable:
|
||||
|
||||
```bash
|
||||
ASSISTANT_NAME=Bot npm start
|
||||
```
|
||||
|
||||
Or edit the default in `src/index.ts`. This changes:
|
||||
- The trigger pattern (messages must start with `@YourName`)
|
||||
- The response prefix (`YourName:`)
|
||||
|
||||
### Placeholder Values in launchd
|
||||
|
||||
Files with `{{PLACEHOLDER}}` values need to be configured:
|
||||
- `{{PROJECT_ROOT}}` - Absolute path to your nanoclaw installation
|
||||
- `{{NODE_PATH}}` - Path to node binary (detected via `which node`)
|
||||
- `{{HOME}}` - User's home directory
|
||||
|
||||
---
|
||||
|
||||
## Memory System
|
||||
|
||||
NanoClaw uses a hierarchical memory system based on CLAUDE.md files.
|
||||
|
||||
### Memory Hierarchy
|
||||
|
||||
| Level | Location | Read By | Written By | Purpose |
|
||||
|-------|----------|---------|------------|---------|
|
||||
| **Global** | `groups/CLAUDE.md` | All groups | Main only | Preferences, facts, context shared across all conversations |
|
||||
| **Group** | `groups/{name}/CLAUDE.md` | That group | That group | Group-specific context, conversation memory |
|
||||
| **Files** | `groups/{name}/*.md` | That group | That group | Notes, research, documents created during conversation |
|
||||
|
||||
### How Memory Works
|
||||
|
||||
1. **Agent Context Loading**
|
||||
- Agent runs with `cwd` set to `groups/{group-name}/`
|
||||
- Claude Agent SDK with `settingSources: ['project']` automatically loads:
|
||||
- `../CLAUDE.md` (parent directory = global memory)
|
||||
- `./CLAUDE.md` (current directory = group memory)
|
||||
|
||||
2. **Writing Memory**
|
||||
- When user says "remember this", agent writes to `./CLAUDE.md`
|
||||
- When user says "remember this globally" (main channel only), agent writes to `../CLAUDE.md`
|
||||
- Agent can create files like `notes.md`, `research.md` in the group folder
|
||||
|
||||
3. **Main Channel Privileges**
|
||||
- Only the "main" group (self-chat) can write to global memory
|
||||
- This prevents other groups from modifying shared context
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
Sessions enable conversation continuity - Claude remembers what you talked about.
|
||||
|
||||
### How Sessions Work
|
||||
|
||||
1. Each group has a session ID stored in `data/sessions.json`
|
||||
2. Session ID is passed to Claude Agent SDK's `resume` option
|
||||
3. Claude continues the conversation with full context
|
||||
|
||||
**data/sessions.json:**
|
||||
```json
|
||||
{
|
||||
"main": "session-abc123",
|
||||
"Family Chat": "session-def456"
|
||||
}
|
||||
```
|
||||
|
||||
### The /clear Command
|
||||
|
||||
When a user sends `/clear` in any group:
|
||||
|
||||
1. Current session ID is moved to `data/archived_sessions.json`
|
||||
2. Session ID is removed from `data/sessions.json`
|
||||
3. Next message starts a fresh session
|
||||
4. **Memory files are NOT deleted** - only the session resets
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
|
||||
### Incoming Message Flow
|
||||
|
||||
```
|
||||
1. User sends WhatsApp message
|
||||
│
|
||||
▼
|
||||
2. Baileys receives message via WhatsApp Web protocol
|
||||
│
|
||||
▼
|
||||
3. Message stored in SQLite (store/messages.db)
|
||||
│
|
||||
▼
|
||||
4. Message loop polls SQLite (every 2 seconds)
|
||||
│
|
||||
▼
|
||||
5. Router checks:
|
||||
├── Is chat_jid in registered_groups.json? → No: ignore
|
||||
├── Does message start with @Assistant? → No: ignore
|
||||
└── Is message "/clear"? → Yes: handle specially
|
||||
│
|
||||
▼
|
||||
6. Router prepares invocation:
|
||||
├── Load session ID for this group
|
||||
├── Determine group folder path
|
||||
└── Strip trigger word from message
|
||||
│
|
||||
▼
|
||||
7. Router invokes Claude Agent SDK:
|
||||
├── cwd: groups/{group-name}/
|
||||
├── prompt: user's message
|
||||
├── resume: session_id (or undefined)
|
||||
└── mcpServers: gmail, scheduler
|
||||
│
|
||||
▼
|
||||
8. Claude processes message:
|
||||
├── Reads CLAUDE.md files for context
|
||||
└── Uses tools as needed (search, email, etc.)
|
||||
│
|
||||
▼
|
||||
9. Router captures result and sends via WhatsApp
|
||||
│
|
||||
▼
|
||||
10. Router saves new session ID
|
||||
```
|
||||
|
||||
### Trigger Word Matching
|
||||
|
||||
Messages must start with the trigger pattern (default: `@Andy`):
|
||||
- `@Andy what's the weather?` → ✅ Triggers Claude
|
||||
- `@andy help me` → ✅ Triggers (case insensitive)
|
||||
- `Hey @Andy` → ❌ Ignored (trigger not at start)
|
||||
- `What's up?` → ❌ Ignored (no trigger)
|
||||
- `/clear` → ✅ Special command (no trigger needed)
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### Commands Available in Any Group
|
||||
|
||||
| Command | Example | Effect |
|
||||
|---------|---------|--------|
|
||||
| `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude |
|
||||
| `/clear` | `/clear` | Reset session, keep memory |
|
||||
|
||||
### Commands Available in Main Channel Only
|
||||
|
||||
| Command | Example | Effect |
|
||||
|---------|---------|--------|
|
||||
| `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | Register a new group |
|
||||
| `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | Unregister a group |
|
||||
| `@Assistant list groups` | `@Andy list groups` | Show registered groups |
|
||||
| `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | Add to global memory |
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
NanoClaw can schedule recurring tasks that run at specified times via the scheduler MCP.
|
||||
|
||||
### Creating a Task
|
||||
|
||||
```
|
||||
User: @Andy remind me every Monday at 9am to review the weekly metrics
|
||||
|
||||
Claude: [calls mcp__scheduler__create_task]
|
||||
{
|
||||
"instruction": "Remind user to review weekly metrics",
|
||||
"trigger_type": "cron",
|
||||
"cron_expression": "0 9 * * 1"
|
||||
}
|
||||
|
||||
Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers
|
||||
|
||||
MCP servers are configured in the Claude Agent SDK options:
|
||||
|
||||
```typescript
|
||||
mcpServers: {
|
||||
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] },
|
||||
scheduler: { command: 'npx', args: ['-y', 'schedule-task-mcp'] }
|
||||
}
|
||||
```
|
||||
|
||||
### Gmail MCP (@gongrzhe/server-gmail-autoauth-mcp)
|
||||
|
||||
Provides email capabilities. Requires Google Cloud OAuth setup.
|
||||
|
||||
**Available Tools:**
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `search_messages` | Search inbox |
|
||||
| `get_message` | Read full email |
|
||||
| `send_message` | Send email |
|
||||
| `reply_message` | Reply to thread |
|
||||
|
||||
### Scheduler MCP (schedule-task-mcp)
|
||||
|
||||
Provides cron-style task scheduling.
|
||||
|
||||
**Available Tools:**
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `create_task` | Schedule a new task |
|
||||
| `list_tasks` | Show scheduled tasks |
|
||||
| `delete_task` | Cancel a task |
|
||||
| `update_task` | Modify schedule |
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
NanoClaw runs as a single macOS launchd service.
|
||||
|
||||
### Service: com.nanoclaw
|
||||
|
||||
**launchd/com.nanoclaw.plist:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{NODE_PATH}}</string>
|
||||
<string>{{PROJECT_ROOT}}/dist/index.js</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{PROJECT_ROOT}}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>{{HOME}}</string>
|
||||
<key>ASSISTANT_NAME</key>
|
||||
<string>Andy</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Managing the Service
|
||||
|
||||
```bash
|
||||
# Install service
|
||||
cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/
|
||||
|
||||
# Start service
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Stop service
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Check status
|
||||
launchctl list | grep nanoclaw
|
||||
|
||||
# View logs
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Prompt Injection Risk
|
||||
|
||||
WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior.
|
||||
|
||||
**Mitigations:**
|
||||
- Only registered groups are processed
|
||||
- Trigger word required (reduces accidental processing)
|
||||
- Main channel has elevated privileges (isolated from other groups)
|
||||
- Claude's built-in safety training
|
||||
|
||||
**Recommendations:**
|
||||
- Only register trusted groups
|
||||
- Review scheduled tasks periodically
|
||||
- Monitor logs for unusual activity
|
||||
|
||||
### Credential Storage
|
||||
|
||||
| Credential | Storage Location | Notes |
|
||||
|------------|------------------|-------|
|
||||
| Claude CLI Auth | ~/.claude/ | Managed by Claude Code CLI |
|
||||
| WhatsApp Session | store/auth/ | Auto-created, persists ~20 days |
|
||||
| Gmail OAuth Tokens | ~/.gmail-mcp/ | Created during setup (optional) |
|
||||
|
||||
### File Permissions
|
||||
|
||||
The groups/ folder contains personal memory and should be protected:
|
||||
```bash
|
||||
chmod 700 groups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
|
||||
| "QR code expired" | WhatsApp session expired | Delete store/auth/ and restart |
|
||||
| "No groups registered" | Haven't added groups | Use `@Andy add group "Name"` in main |
|
||||
| Session not continuing | Session ID not saved | Check `data/sessions.json` |
|
||||
|
||||
### Log Location
|
||||
|
||||
- `logs/nanoclaw.log` - stdout
|
||||
- `logs/nanoclaw.error.log` - stderr
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Run manually for verbose output:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
node dist/index.js
|
||||
```
|
||||
30
groups/CLAUDE.md
Normal file
30
groups/CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# NanoClaw Global Memory
|
||||
|
||||
This file is read by all group conversations. Only the main channel can write here.
|
||||
|
||||
## About
|
||||
|
||||
Personal Claude assistant via WhatsApp.
|
||||
|
||||
Assistant name is configured in `src/config.py` (ASSISTANT_NAME).
|
||||
|
||||
## Commands
|
||||
|
||||
From any group:
|
||||
- `@{name} [message]` - Talk to Claude
|
||||
- `/clear` - Reset conversation (keeps memory)
|
||||
- `@{name} list tasks` - Show scheduled tasks
|
||||
|
||||
From main channel only:
|
||||
- `@{name} add group "Name"` - Register a new group
|
||||
- `@{name} remove group "Name"` - Unregister a group
|
||||
- `@{name} list groups` - Show registered groups
|
||||
- `@{name} remember [fact]` - Add to global memory
|
||||
|
||||
## Preferences
|
||||
|
||||
<!-- Add global preferences here -->
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Add persistent notes here -->
|
||||
18
groups/main/CLAUDE.md
Normal file
18
groups/main/CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Main Channel
|
||||
|
||||
Self-chat - the primary control channel for NanoClaw.
|
||||
|
||||
## Permissions
|
||||
|
||||
This channel can:
|
||||
- Write to global memory (../CLAUDE.md)
|
||||
- Add/remove groups
|
||||
- Manage scheduled tasks across all groups
|
||||
|
||||
## Memory
|
||||
|
||||
<!-- Personal notes and context -->
|
||||
|
||||
## Files
|
||||
|
||||
<!-- Reference files created in this folder -->
|
||||
32
launchd/com.nanoclaw.plist
Normal file
32
launchd/com.nanoclaw.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{NODE_PATH}}</string>
|
||||
<string>{{PROJECT_ROOT}}/dist/index.js</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{PROJECT_ROOT}}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>{{HOME}}</string>
|
||||
<key>ASSISTANT_NAME</key>
|
||||
<string>Andy</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
2740
package-lock.json
generated
Normal file
2740
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal Claude assistant via WhatsApp",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"qrcode-terminal": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
457
src/index.ts
Normal file
457
src/index.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* NanoClaw - Unified Node.js Implementation
|
||||
*
|
||||
* Single process that handles:
|
||||
* - WhatsApp connection (baileys)
|
||||
* - Message routing
|
||||
* - Claude Agent SDK queries
|
||||
* - Response sending
|
||||
*/
|
||||
|
||||
import makeWASocket, {
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
makeCacheableSignalKeyStore,
|
||||
WASocket,
|
||||
proto
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import pino from 'pino';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// === CONFIGURATION ===
|
||||
|
||||
const CONFIG = {
|
||||
assistantName: process.env.ASSISTANT_NAME || 'Andy',
|
||||
pollInterval: 2000, // ms
|
||||
storeDir: './store',
|
||||
groupsDir: './groups',
|
||||
dataDir: './data',
|
||||
};
|
||||
|
||||
const TRIGGER_PATTERN = new RegExp(`^@${CONFIG.assistantName}\\b`, 'i');
|
||||
const CLEAR_COMMAND = '/clear';
|
||||
|
||||
// === TYPES ===
|
||||
|
||||
interface RegisteredGroup {
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger: string;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
[folder: string]: string; // folder -> session_id
|
||||
}
|
||||
|
||||
// === LOGGING ===
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true }
|
||||
}
|
||||
});
|
||||
|
||||
// === DATABASE ===
|
||||
|
||||
function initDatabase(dbPath: string): Database.Database {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT,
|
||||
chat_jid TEXT,
|
||||
sender TEXT,
|
||||
content TEXT,
|
||||
timestamp TEXT,
|
||||
is_from_me INTEGER,
|
||||
PRIMARY KEY (id, chat_jid),
|
||||
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// === FILE HELPERS ===
|
||||
|
||||
function loadJson<T>(filePath: string, defaultValue: T): T {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn({ filePath, error: e }, 'Failed to load JSON file');
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function saveJson(filePath: string, data: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// === STATE ===
|
||||
|
||||
let db: Database.Database;
|
||||
let sock: WASocket;
|
||||
let lastTimestamp = '';
|
||||
let sessions: Session = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
|
||||
function loadState(): void {
|
||||
const statePath = path.join(CONFIG.dataDir, 'router_state.json');
|
||||
const state = loadJson<{ last_timestamp?: string }>(statePath, {});
|
||||
lastTimestamp = state.last_timestamp || '';
|
||||
|
||||
sessions = loadJson(path.join(CONFIG.dataDir, 'sessions.json'), {});
|
||||
registeredGroups = loadJson(path.join(CONFIG.dataDir, 'registered_groups.json'), {});
|
||||
|
||||
logger.info({
|
||||
groupCount: Object.keys(registeredGroups).length,
|
||||
lastTimestamp: lastTimestamp || '(start)'
|
||||
}, 'State loaded');
|
||||
}
|
||||
|
||||
function saveState(): void {
|
||||
saveJson(path.join(CONFIG.dataDir, 'router_state.json'), { last_timestamp: lastTimestamp });
|
||||
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
|
||||
}
|
||||
|
||||
// === MESSAGE STORAGE ===
|
||||
|
||||
function storeMessage(
|
||||
msg: proto.IWebMessageInfo,
|
||||
chatJid: string,
|
||||
isFromMe: boolean
|
||||
): void {
|
||||
if (!msg.key) return;
|
||||
|
||||
const content =
|
||||
msg.message?.conversation ||
|
||||
msg.message?.extendedTextMessage?.text ||
|
||||
msg.message?.imageMessage?.caption ||
|
||||
msg.message?.videoMessage?.caption ||
|
||||
'';
|
||||
|
||||
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
|
||||
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||
const msgId = msg.key.id || '';
|
||||
|
||||
try {
|
||||
// Ensure chat exists first
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO chats (jid, name, last_message_time)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(chatJid, chatJid, timestamp);
|
||||
|
||||
// Store message
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(msgId, chatJid, sender, content, timestamp, isFromMe ? 1 : 0);
|
||||
|
||||
logger.debug({ chatJid, msgId }, 'Message stored');
|
||||
} catch (err) {
|
||||
logger.error({ err, msgId }, 'Failed to store message');
|
||||
}
|
||||
}
|
||||
|
||||
// === MESSAGE PROCESSING ===
|
||||
|
||||
interface NewMessage {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
sender: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function getNewMessages(): NewMessage[] {
|
||||
const jids = Object.keys(registeredGroups);
|
||||
if (jids.length === 0) {
|
||||
logger.debug('No registered groups');
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
const query = `
|
||||
SELECT id, chat_jid, sender, content, timestamp
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
ORDER BY timestamp
|
||||
`;
|
||||
|
||||
logger.debug({ lastTimestamp, jids }, 'Querying messages');
|
||||
|
||||
const rows = db.prepare(query).all(lastTimestamp, ...jids) as NewMessage[];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.timestamp > lastTimestamp) {
|
||||
lastTimestamp = row.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function processMessage(msg: NewMessage): Promise<void> {
|
||||
const group = registeredGroups[msg.chat_jid];
|
||||
if (!group) return;
|
||||
|
||||
const content = msg.content.trim();
|
||||
|
||||
// Handle /clear command
|
||||
if (content.toLowerCase() === CLEAR_COMMAND) {
|
||||
if (sessions[group.folder]) {
|
||||
// Archive old session
|
||||
const archived = loadJson<Record<string, Array<{ session_id: string; cleared_at: string }>>>(
|
||||
path.join(CONFIG.dataDir, 'archived_sessions.json'),
|
||||
{}
|
||||
);
|
||||
if (!archived[group.folder]) archived[group.folder] = [];
|
||||
archived[group.folder].push({
|
||||
session_id: sessions[group.folder],
|
||||
cleared_at: new Date().toISOString()
|
||||
});
|
||||
saveJson(path.join(CONFIG.dataDir, 'archived_sessions.json'), archived);
|
||||
|
||||
delete sessions[group.folder];
|
||||
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
|
||||
}
|
||||
|
||||
logger.info({ group: group.name }, 'Session cleared');
|
||||
await sendMessage(msg.chat_jid, `${CONFIG.assistantName}: Conversation cleared. Starting fresh!`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check trigger pattern
|
||||
if (!TRIGGER_PATTERN.test(content)) return;
|
||||
|
||||
// Strip trigger from message
|
||||
const prompt = content.replace(TRIGGER_PATTERN, '').trim();
|
||||
if (!prompt) return;
|
||||
|
||||
logger.info({ group: group.name, prompt: prompt.slice(0, 50) }, 'Processing message');
|
||||
|
||||
// Run agent
|
||||
const response = await runAgent(group, prompt, msg.chat_jid);
|
||||
|
||||
if (response) {
|
||||
await sendMessage(msg.chat_jid, response);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgent(
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string
|
||||
): Promise<string | null> {
|
||||
const isMain = group.folder === 'main';
|
||||
const groupDir = path.join(CONFIG.groupsDir, group.folder);
|
||||
|
||||
// Ensure group directory exists
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
// Build context
|
||||
const context = `[WhatsApp message from group: ${group.name}]
|
||||
[Reply to chat_jid: ${chatJid}]
|
||||
[Can write to global memory (../CLAUDE.md): ${isMain}]
|
||||
[Prefix your responses with "${CONFIG.assistantName}:"]
|
||||
|
||||
User message: ${prompt}`;
|
||||
|
||||
const sessionId = sessions[group.folder];
|
||||
let newSessionId: string | undefined;
|
||||
let result: string | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt: context,
|
||||
options: {
|
||||
cwd: groupDir,
|
||||
resume: sessionId,
|
||||
allowedTools: [
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch'
|
||||
],
|
||||
permissionMode: 'bypassPermissions',
|
||||
settingSources: ['project'],
|
||||
mcpServers: {
|
||||
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] },
|
||||
scheduler: { command: 'npx', args: ['-y', 'schedule-task-mcp'] }
|
||||
}
|
||||
}
|
||||
})) {
|
||||
// Capture session ID from init message
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
newSessionId = message.session_id;
|
||||
}
|
||||
|
||||
// Capture final result
|
||||
if ('result' in message && message.result) {
|
||||
result = message.result as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ group: group.name, err }, 'Agent error');
|
||||
return `${CONFIG.assistantName}: Sorry, I encountered an error. Please try again.`;
|
||||
}
|
||||
|
||||
// Save session
|
||||
if (newSessionId) {
|
||||
sessions[group.folder] = newSessionId;
|
||||
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger.info({ group: group.name, result: result.slice(0, 100) }, 'Agent response');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function sendMessage(jid: string, text: string): Promise<void> {
|
||||
try {
|
||||
await sock.sendMessage(jid, { text });
|
||||
logger.info({ jid, text: text.slice(0, 50) }, 'Message sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send message');
|
||||
}
|
||||
}
|
||||
|
||||
// === WHATSAPP CONNECTION ===
|
||||
|
||||
async function connectWhatsApp(): Promise<void> {
|
||||
const authDir = path.join(CONFIG.storeDir, 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger)
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: ['NanoClaw', 'Chrome', '1.0.0']
|
||||
});
|
||||
|
||||
// Handle connection updates
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('\nScan this QR code with WhatsApp:\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\nWaiting for scan...\n');
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
|
||||
logger.info({ reason, shouldReconnect }, 'Connection closed');
|
||||
|
||||
if (shouldReconnect) {
|
||||
logger.info('Reconnecting...');
|
||||
connectWhatsApp();
|
||||
} else {
|
||||
logger.info('Logged out. Delete store/auth folder and restart to re-authenticate.');
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
console.log('\n✓ Connected to WhatsApp!\n');
|
||||
logger.info('WhatsApp connection established');
|
||||
startMessageLoop();
|
||||
}
|
||||
});
|
||||
|
||||
// Save credentials on update
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// Handle incoming messages (store them)
|
||||
sock.ev.on('messages.upsert', ({ messages }) => {
|
||||
for (const msg of messages) {
|
||||
if (!msg.message) continue;
|
||||
|
||||
const chatJid = msg.key.remoteJid;
|
||||
if (!chatJid || chatJid === 'status@broadcast') continue;
|
||||
|
||||
storeMessage(msg, chatJid, msg.key.fromMe || false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === MAIN LOOP ===
|
||||
|
||||
async function startMessageLoop(): Promise<void> {
|
||||
logger.info(`NanoClaw running (trigger: @${CONFIG.assistantName})`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const messages = getNewMessages();
|
||||
|
||||
if (messages.length > 0) {
|
||||
logger.info({ count: messages.length }, 'Found new messages');
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
await processMessage(msg);
|
||||
}
|
||||
|
||||
saveState();
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in message loop');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, CONFIG.pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// === ENTRY POINT ===
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Initialize database
|
||||
const dbPath = path.join(CONFIG.storeDir, 'messages.db');
|
||||
db = initDatabase(dbPath);
|
||||
logger.info('Database initialized');
|
||||
|
||||
// Load state
|
||||
loadState();
|
||||
|
||||
// Connect to WhatsApp
|
||||
await connectWhatsApp();
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Shutting down...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Shutting down...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
logger.error({ err }, 'Failed to start NanoClaw');
|
||||
process.exit(1);
|
||||
});
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user