feat: add Discord channel, OpenCode runtime, rename to Regolith
Some checks failed
Update token count / update-tokens (push) Has been cancelled
Some checks failed
Update token count / update-tokens (push) Has been cancelled
- Discord channel integration (discord.js) with mention stripping, attachment tagging, reply context, message chunking - OpenCode runtime with CLI and SDK modes, SQLite session persistence, idle timeout cleanup - Multi-channel architecture: WhatsApp + Discord simultaneous or independent via DISCORD_ONLY/DISCORD_BOT_TOKEN - Config additions: DISCORD_BOT_TOKEN, DISCORD_ONLY, OPENCODE_MODE, OPENCODE_MODEL, OPENCODE_TIMEOUT, OPENCODE_SESSION_TTL_HOURS - Updated README and CLAUDE.md documentation - Renamed project to Regolith in package.json
This commit is contained in:
54
CLAUDE.md
54
CLAUDE.md
@@ -1,25 +1,41 @@
|
||||
# NanoClaw
|
||||
# Regolith
|
||||
|
||||
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions.
|
||||
Personal AI assistant with multi-channel support (WhatsApp + Discord) and multi-runtime backends (Claude Agent SDK + OpenCode). See [README.md](README.md) for setup.
|
||||
|
||||
## Quick Context
|
||||
|
||||
Single Node.js process that connects to WhatsApp, routes messages to Claude Agent SDK running in Apple Container (Linux VMs). Each group has isolated filesystem and memory.
|
||||
Single Node.js process that connects to WhatsApp and/or Discord, routes messages to Claude Agent SDK (in containers) or OpenCode runtime. Each group has isolated filesystem and memory.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
|
||||
| `src/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation |
|
||||
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
|
||||
| `src/ipc.ts` | IPC watcher and task processing |
|
||||
| `src/router.ts` | Message formatting and outbound routing |
|
||||
| `src/config.ts` | Trigger pattern, paths, intervals |
|
||||
| `src/channels/discord.ts` | Discord bot connection, mention handling, attachments, reply context |
|
||||
| `src/channels/chunk-text.ts` | Message chunking (2000-char Discord limit) |
|
||||
| `src/router.ts` | Message formatting, outbound routing, `findChannel` |
|
||||
| `src/config.ts` | Trigger pattern, paths, intervals, Discord/OpenCode config |
|
||||
| `src/opencode/runtime.ts` | OpenCode AI backend (CLI + SDK modes) |
|
||||
| `src/opencode/session-store.ts` | SQLite session persistence for OpenCode |
|
||||
| `src/opencode/live-sessions.ts` | In-memory session manager with idle cleanup |
|
||||
| `src/opencode/types.ts` | OpenCode type definitions |
|
||||
| `src/container-runner.ts` | Spawns agent containers with mounts |
|
||||
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
||||
| `src/db.ts` | SQLite operations |
|
||||
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
||||
| `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ASSISTANT_NAME` | Andy | Trigger word for the bot |
|
||||
| `DISCORD_BOT_TOKEN` | (empty) | Discord bot token; set to enable Discord |
|
||||
| `DISCORD_ONLY` | false | Skip WhatsApp when true |
|
||||
| `OPENCODE_MODE` | cli | OpenCode mode: "cli" or "sdk" |
|
||||
| `OPENCODE_MODEL` | (unset) | Model name for OpenCode |
|
||||
| `OPENCODE_TIMEOUT` | 120 | Timeout in seconds |
|
||||
| `OPENCODE_SESSION_TTL_HOURS` | 24 | Session TTL in hours |
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -31,27 +47,9 @@ Single Node.js process that connects to WhatsApp, routes messages to Claude Agen
|
||||
|
||||
## Development
|
||||
|
||||
Run commands directly—don't tell the user to run them.
|
||||
|
||||
```bash
|
||||
npm run dev # Run with hot reload
|
||||
npm run build # Compile TypeScript
|
||||
./container/build.sh # Rebuild agent container
|
||||
npm test # Run tests
|
||||
npm run typecheck # Type check without emitting
|
||||
```
|
||||
|
||||
Service management:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
```
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
Apple Container's buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild:
|
||||
|
||||
```bash
|
||||
container builder stop && container builder rm && container builder start
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Always verify after rebuild: `container run -i --rm --entrypoint wc nanoclaw-agent:latest -l /app/src/index.ts`
|
||||
|
||||
243
README.md
243
README.md
@@ -1,197 +1,156 @@
|
||||
<p align="center">
|
||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
||||
<img src="assets/nanoclaw-logo.png" alt="Regolith" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
My personal Claude assistant that runs securely in containers. Lightweight and built to be understood and customized for your own needs.
|
||||
Personal AI assistant with multi-channel messaging (WhatsApp + Discord) and multi-runtime backends (Claude Agent SDK + OpenCode). Lightweight, secure, runs agents in containers.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
**New:** First AI assistant to support [Agent Swarms](https://code.claude.com/docs/en/agent-teams). Spin up teams of agents that collaborate in your chat.
|
||||
## What Is Regolith
|
||||
|
||||
## Why I Built This
|
||||
Regolith is a fork of [NanoClaw](https://github.com/gavrielc/nanoclaw) extended with:
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project with a great vision. But I can't sleep well running software I don't understand with access to my life. OpenClaw has 52+ modules, 8 config management files, 45+ dependencies, and abstractions for 15 channel providers. Security is application-level (allowlists, pairing codes) rather than OS isolation. Everything runs in one Node process with shared memory.
|
||||
- **Discord channel support** — Talk to your AI assistant through Discord DMs and guild channels alongside WhatsApp
|
||||
- **OpenCode runtime** — Use OpenCode as an alternative AI agent backend (CLI or SDK mode) with persistent session management
|
||||
- **Multi-channel architecture** — Run WhatsApp-only, Discord-only, or both simultaneously via environment variables
|
||||
|
||||
NanoClaw gives you the same core functionality in a codebase you can understand in 8 minutes. One process. A handful of files. Agents run in actual Linux containers with filesystem isolation, not behind permission checks.
|
||||
The core philosophy remains the same: small enough to understand, secure by container isolation, built for one user.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/gavrielc/nanoclaw.git
|
||||
cd nanoclaw
|
||||
claude
|
||||
git clone http://10.0.0.59:3051/tanmay/Regolith.git
|
||||
cd Regolith/nanoclaw
|
||||
npm install
|
||||
```
|
||||
|
||||
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup, service configuration.
|
||||
Configure your `.env`:
|
||||
```bash
|
||||
# WhatsApp (enabled by default)
|
||||
ASSISTANT_NAME=Andy
|
||||
|
||||
## Philosophy
|
||||
# Discord (optional — set token to enable)
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_ONLY=false # set to true to disable WhatsApp
|
||||
|
||||
**Small enough to understand.** One process, a few source files. No microservices, no message queues, no abstraction layers. Have Claude Code walk you through it.
|
||||
# OpenCode runtime (optional)
|
||||
OPENCODE_MODE=cli # or "sdk"
|
||||
OPENCODE_MODEL=claude # model name
|
||||
OPENCODE_TIMEOUT=120 # seconds
|
||||
OPENCODE_SESSION_TTL_HOURS=24
|
||||
```
|
||||
|
||||
**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker). They can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
|
||||
Then run:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Built for one user.** This isn't a framework. It's working software that fits my exact needs. You fork it and have Claude Code make it match your exact needs.
|
||||
## Architecture
|
||||
|
||||
**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that this is safe.
|
||||
```
|
||||
WhatsApp (baileys) ──┐
|
||||
├──> SQLite ──> Polling loop ──> Container (Claude Agent SDK) ──> Response
|
||||
Discord (discord.js)─┘ └──> OpenCode Runtime (CLI/SDK) ──> Response
|
||||
```
|
||||
|
||||
**AI-native.** No installation wizard; Claude Code guides setup. No monitoring dashboard; ask Claude what's happening. No debugging tools; describe the problem, Claude fixes it.
|
||||
Single Node.js process. Multiple messaging channels route through a shared pipeline. Agents execute in isolated Linux containers or via OpenCode subprocess/HTTP.
|
||||
|
||||
**Skills over features.** Contributors shouldn't add features (e.g. support for Telegram) to the codebase. Instead, they contribute [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need.
|
||||
### Channel Routing
|
||||
|
||||
**Best harness, best model.** This runs on Claude Agent SDK, which means you're running Claude Code directly. The harness matters. A bad harness makes even smart models seem dumb, a good harness gives them superpowers. Claude Code is (IMO) the best harness available.
|
||||
Messages are routed by JID prefix:
|
||||
- WhatsApp JIDs: phone-number format (e.g., `1234567890@s.whatsapp.net`)
|
||||
- Discord JIDs: `dc:` prefix (e.g., `dc:1234567890123456`)
|
||||
|
||||
The `findChannel(channels, jid)` function resolves which channel owns a given JID.
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation |
|
||||
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
|
||||
| `src/channels/discord.ts` | Discord bot connection, mention handling, attachment tagging |
|
||||
| `src/channels/chunk-text.ts` | Message chunking utility (2000-char Discord limit) |
|
||||
| `src/router.ts` | Message formatting, outbound routing, `findChannel` |
|
||||
| `src/config.ts` | Environment config (trigger pattern, Discord token, paths) |
|
||||
| `src/opencode/runtime.ts` | OpenCode AI backend (CLI + SDK modes) |
|
||||
| `src/opencode/session-store.ts` | SQLite-backed session persistence |
|
||||
| `src/opencode/live-sessions.ts` | In-memory session manager with idle cleanup |
|
||||
| `src/opencode/types.ts` | OpenCode type definitions |
|
||||
| `src/container-runner.ts` | Spawns streaming agent containers |
|
||||
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
||||
| `src/db.ts` | SQLite operations (messages, groups, sessions, state) |
|
||||
| `groups/*/CLAUDE.md` | Per-group memory (isolated) |
|
||||
|
||||
## Discord Setup
|
||||
|
||||
1. Create a Discord bot at [discord.com/developers](https://discord.com/developers/applications)
|
||||
2. Enable the following intents: Guilds, Guild Messages, Message Content, Direct Messages
|
||||
3. Set `DISCORD_BOT_TOKEN` in your `.env`
|
||||
4. Invite the bot to your server with message read/write permissions
|
||||
5. Register Discord channels the same way you register WhatsApp groups
|
||||
|
||||
The bot responds to @mentions in guild channels and all DMs. Messages with attachments include type-tagged placeholders (`[Image: name]`, `[Video: name]`, etc.). Reply context is preserved as `[Reply to AuthorName]`.
|
||||
|
||||
## OpenCode Runtime
|
||||
|
||||
The OpenCode runtime provides an alternative AI backend:
|
||||
|
||||
- **CLI mode** (default): Spawns `opencode run` subprocesses with model, session, and system prompt args
|
||||
- **SDK mode**: Sends HTTP requests to an `opencode serve` endpoint
|
||||
|
||||
Sessions are persisted in SQLite and tracked in memory with idle timeout cleanup (default 30 min). Empty/whitespace messages are rejected immediately. All errors are caught and returned as structured `AgentResponse` objects.
|
||||
|
||||
## What It Supports
|
||||
|
||||
- **WhatsApp I/O** - Message Claude from your phone
|
||||
- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted
|
||||
- **Main channel** - Your private channel (self-chat) for admin control; every other group is completely isolated
|
||||
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
|
||||
- **Web access** - Search and fetch content
|
||||
- **Container isolation** - Agents sandboxed in Apple Container (macOS) or Docker (macOS/Linux)
|
||||
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks (first personal AI assistant to support this)
|
||||
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
|
||||
- **WhatsApp I/O** — Message your assistant from your phone
|
||||
- **Discord I/O** — Message your assistant from Discord (DMs and guild channels)
|
||||
- **Multi-channel** — Run WhatsApp + Discord simultaneously, or either alone
|
||||
- **OpenCode backend** — Alternative AI runtime with CLI and SDK modes
|
||||
- **Isolated group context** — Each group has its own memory and container sandbox
|
||||
- **Scheduled tasks** — Recurring jobs that run agents and message you back
|
||||
- **Container isolation** — Agents sandboxed in Apple Container (macOS) or Docker
|
||||
- **Agent Swarms** — Teams of specialized agents that collaborate on tasks
|
||||
- **Skills system** — Add capabilities via Claude Code skills
|
||||
|
||||
## Usage
|
||||
|
||||
Talk to your assistant with the trigger word (default: `@Andy`):
|
||||
|
||||
```
|
||||
@Andy send an overview of the sales pipeline every weekday morning at 9am (has access to my Obsidian vault folder)
|
||||
@Andy review the git history for the past week each Friday and update the README if there's drift
|
||||
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
|
||||
@Andy send an overview of the sales pipeline every weekday morning at 9am
|
||||
@Andy review the git history for the past week each Friday
|
||||
@Andy every Monday at 8am, compile AI news from Hacker News
|
||||
```
|
||||
|
||||
From the main channel (your self-chat), you can manage groups and tasks:
|
||||
On Discord, @mention the bot:
|
||||
```
|
||||
@Andy list all scheduled tasks across groups
|
||||
@Andy pause the Monday briefing task
|
||||
@Andy join the Family Chat group
|
||||
@YourBot what's the status of the project?
|
||||
@YourBot summarize the last 10 messages in this channel
|
||||
```
|
||||
|
||||
## Customizing
|
||||
|
||||
There are no configuration files to learn. Just tell Claude Code what you want:
|
||||
Tell Claude Code what you want:
|
||||
|
||||
- "Change the trigger word to @Bob"
|
||||
- "Remember in the future to make responses shorter and more direct"
|
||||
- "Make responses shorter and more direct"
|
||||
- "Add a custom greeting when I say good morning"
|
||||
- "Store conversation summaries weekly"
|
||||
|
||||
Or run `/customize` for guided changes.
|
||||
|
||||
The codebase is small enough that Claude can safely modify it.
|
||||
|
||||
## Skills System CLI (Experimental)
|
||||
|
||||
The new deterministic skills-system primitives are available as local commands:
|
||||
|
||||
```bash
|
||||
npm run skills:init -- --core-version 0.5.0 --base-source .
|
||||
npm run skills:apply -- --skill whatsapp --version 1.2.0 --files-modified src/server.ts
|
||||
npm run skills:update-preview
|
||||
npm run skills:update-stage -- --target-core-version 0.6.0 --base-source /path/to/new/core
|
||||
npm run skills:update-commit
|
||||
# or: npm run skills:update-rollback
|
||||
```
|
||||
|
||||
These commands operate on `.nanoclaw/state.yaml`, `.nanoclaw/state.next.yaml`, `.nanoclaw/base/`, `.nanoclaw/base.next/`, and `.nanoclaw/backup/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
**Don't add features. Add skills.**
|
||||
|
||||
If you want to add Telegram support, don't create a PR that adds Telegram alongside WhatsApp. Instead, contribute a skill file (`.claude/skills/add-telegram/SKILL.md`) that teaches Claude Code how to transform a NanoClaw installation to use Telegram.
|
||||
|
||||
Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case.
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd love to see:
|
||||
|
||||
**Communication Channels**
|
||||
- `/add-telegram` - Add Telegram as channel. Should give the user option to replace WhatsApp or add as additional channel. Also should be possible to add it as a control channel (where it can trigger actions) or just a channel that can be used in actions triggered elsewhere
|
||||
- `/add-slack` - Add Slack
|
||||
- `/add-discord` - Add Discord
|
||||
|
||||
**Platform Support**
|
||||
- `/setup-windows` - Windows via WSL2 + Docker
|
||||
|
||||
**Session Management**
|
||||
- `/add-clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux
|
||||
- macOS or Linux (Windows via WSL2)
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
WhatsApp (baileys) --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
|
||||
```
|
||||
|
||||
Single Node.js process. Agents execute in isolated Linux containers with mounted directories. Per-group message queue with concurrency control. IPC via filesystem.
|
||||
|
||||
Key files:
|
||||
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
|
||||
- `src/channels/whatsapp.ts` - WhatsApp connection, auth, send/receive
|
||||
- `src/ipc.ts` - IPC watcher and task processing
|
||||
- `src/router.ts` - Message formatting and outbound routing
|
||||
- `src/group-queue.ts` - Per-group queue with global concurrency limit
|
||||
- `src/container-runner.ts` - Spawns streaming agent containers
|
||||
- `src/task-scheduler.ts` - Runs scheduled tasks
|
||||
- `src/db.ts` - SQLite operations (messages, groups, sessions, state)
|
||||
- `groups/*/CLAUDE.md` - Per-group memory
|
||||
|
||||
## FAQ
|
||||
|
||||
**Why WhatsApp and not Telegram/Signal/etc?**
|
||||
|
||||
Because I use WhatsApp. Fork it and run a skill to change it. That's the whole point.
|
||||
|
||||
**Why Apple Container instead of Docker?**
|
||||
|
||||
On macOS, Apple Container is lightweight, fast, and optimized for Apple silicon. But Docker is also fully supported—during `/setup`, you can choose which runtime to use. On Linux, Docker is used automatically.
|
||||
|
||||
**Can I run this on Linux?**
|
||||
|
||||
Yes. Run `/setup` and it will automatically configure Docker as the container runtime. Thanks to [@dotsetgreg](https://github.com/dotsetgreg) for contributing the `/convert-to-docker` skill.
|
||||
|
||||
**Is this secure?**
|
||||
|
||||
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model.
|
||||
|
||||
**Why no configuration files?**
|
||||
|
||||
We don't want configuration sprawl. Every user should customize it to so that the code matches exactly what they want rather than configuring a generic system. If you like having config files, tell Claude to add them.
|
||||
|
||||
**How do I debug issues?**
|
||||
|
||||
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach.
|
||||
|
||||
**Why isn't the setup working for me?**
|
||||
|
||||
I don't know. Run `claude`, then run `/debug`. If claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md.
|
||||
|
||||
**What changes will be accepted into the codebase?**
|
||||
|
||||
Security fixes, bug fixes, and clear improvements to the base configuration. That's it.
|
||||
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills.
|
||||
|
||||
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
|
||||
|
||||
## Community
|
||||
|
||||
Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop)
|
||||
- Discord bot token (for Discord channel)
|
||||
- OpenCode binary (for OpenCode runtime, optional)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
299
package-lock.json
generated
299
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"fast-check": "^4.5.3",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
@@ -138,6 +140,136 @@
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
|
||||
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@sapphire/shapeshift": "^4.0.0",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"ts-mixer": "^6.0.4",
|
||||
"tslib": "^2.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/collection": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
||||
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/formatters": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
||||
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
|
||||
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.1.1",
|
||||
"@sapphire/async-queue": "^1.5.3",
|
||||
"@sapphire/snowflake": "^3.5.3",
|
||||
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||
"discord-api-types": "^0.38.16",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/util": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.0",
|
||||
"@discordjs/rest": "^2.5.1",
|
||||
"@discordjs/util": "^1.1.0",
|
||||
"@sapphire/async-queue": "^1.5.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vladfrangu/async_event_emitter": "^2.2.4",
|
||||
"discord-api-types": "^0.38.1",
|
||||
"tslib": "^2.6.2",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
@@ -1566,6 +1698,39 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/shapeshift": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
||||
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v16"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/snowflake": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1653,6 +1818,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
|
||||
@@ -1795,6 +1969,16 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vladfrangu/async_event_emitter": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@whiskeysockets/baileys": {
|
||||
"version": "7.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
|
||||
@@ -2147,6 +2331,42 @@
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.38.39",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz",
|
||||
"integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"scripts/actions/documentation"
|
||||
]
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "14.25.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
||||
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.13.0",
|
||||
"@discordjs/collection": "1.5.3",
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/rest": "^2.6.0",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@discordjs/ws": "^1.2.3",
|
||||
"@sapphire/snowflake": "3.5.3",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2246,12 +2466,41 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz",
|
||||
"integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
@@ -2568,6 +2817,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.snakecase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -2592,6 +2853,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-bytes.js": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
|
||||
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -3069,6 +3336,23 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||
"integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qified": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
|
||||
@@ -3606,6 +3890,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3670,6 +3960,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"name": "regolith",
|
||||
"version": "2.0.0",
|
||||
"description": "Personal AI assistant with multi-channel support (WhatsApp, Discord) and multi-runtime backends (Claude Agent SDK, OpenCode).",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"fast-check": "^4.5.3",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
118
src/channels/chunk-text.test.ts
Normal file
118
src/channels/chunk-text.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { chunkText } from './chunk-text.js';
|
||||
|
||||
describe('chunkText', () => {
|
||||
// --- Unit tests ---
|
||||
|
||||
it('returns single chunk when text is within limit', () => {
|
||||
const text = 'Hello, world!';
|
||||
expect(chunkText(text)).toEqual([text]);
|
||||
});
|
||||
|
||||
it('returns single chunk when text is exactly at limit', () => {
|
||||
const text = 'a'.repeat(2000);
|
||||
expect(chunkText(text)).toEqual([text]);
|
||||
});
|
||||
|
||||
it('returns empty string as single chunk', () => {
|
||||
expect(chunkText('')).toEqual(['']);
|
||||
});
|
||||
|
||||
it('splits at last newline in latter half', () => {
|
||||
const firstPart = 'a'.repeat(1500) + '\n';
|
||||
const secondPart = 'b'.repeat(600);
|
||||
const text = firstPart + secondPart;
|
||||
const chunks = chunkText(text);
|
||||
expect(chunks[0]).toBe(firstPart);
|
||||
expect(chunks[1]).toBe(secondPart);
|
||||
});
|
||||
|
||||
it('splits at last space in latter half when no newline', () => {
|
||||
const firstPart = 'a'.repeat(1500) + ' ';
|
||||
const secondPart = 'b'.repeat(600);
|
||||
const text = firstPart + secondPart;
|
||||
const chunks = chunkText(text);
|
||||
expect(chunks[0]).toBe(firstPart);
|
||||
expect(chunks[1]).toBe(secondPart);
|
||||
});
|
||||
|
||||
it('hard cuts when no newline or space in latter half', () => {
|
||||
const text = 'a'.repeat(3000);
|
||||
const chunks = chunkText(text);
|
||||
expect(chunks[0]).toBe('a'.repeat(2000));
|
||||
expect(chunks[1]).toBe('a'.repeat(1000));
|
||||
});
|
||||
|
||||
it('prefers newline over space', () => {
|
||||
// Both newline and space in latter half — newline should win
|
||||
const before = 'a'.repeat(1200);
|
||||
const text = before + ' ' + 'b'.repeat(200) + '\n' + 'c'.repeat(800);
|
||||
const chunks = chunkText(text);
|
||||
// Should split at the newline, not the space
|
||||
const newlineIdx = text.lastIndexOf('\n', 1999);
|
||||
expect(chunks[0]).toBe(text.slice(0, newlineIdx + 1));
|
||||
});
|
||||
|
||||
it('respects custom limit', () => {
|
||||
const text = 'abcdefghij'; // 10 chars
|
||||
const chunks = chunkText(text, 4);
|
||||
expect(chunks.join('')).toBe(text);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Property-based tests ---
|
||||
// Feature: nanoclaw-go-app, Property 11: Message Chunking Invariant
|
||||
// **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
|
||||
|
||||
it('property: all chunks are within the limit', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.integer({ min: 1, max: 5000 }), (text, limit) => {
|
||||
const chunks = chunkText(text, limit);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: concatenation of chunks equals original text (round-trip)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.integer({ min: 1, max: 5000 }), (text, limit) => {
|
||||
const chunks = chunkText(text, limit);
|
||||
expect(chunks.join('')).toBe(text);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: text within limit returns single chunk', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ maxLength: 2000 }), (text) => {
|
||||
const chunks = chunkText(text);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]).toBe(text);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: no empty chunks except for empty input', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.integer({ min: 1, max: 5000 }), (text, limit) => {
|
||||
const chunks = chunkText(text, limit);
|
||||
if (text.length === 0) {
|
||||
expect(chunks).toEqual(['']);
|
||||
} else {
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
49
src/channels/chunk-text.ts
Normal file
49
src/channels/chunk-text.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Splits text into chunks that fit within a character limit.
|
||||
*
|
||||
* Splitting priority:
|
||||
* 1. Last newline in the latter half of the chunk
|
||||
* 2. Last space in the latter half of the chunk
|
||||
* 3. Hard cut at exactly the character limit
|
||||
*
|
||||
* Concatenating all returned chunks reconstructs the original text.
|
||||
*/
|
||||
export function chunkText(text: string, limit: number = 2000): string[] {
|
||||
if (text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > limit) {
|
||||
const window = remaining.slice(0, limit);
|
||||
const halfPoint = Math.floor(limit / 2);
|
||||
|
||||
// Try last newline in the latter half
|
||||
const lastNewline = window.lastIndexOf('\n', limit - 1);
|
||||
if (lastNewline >= halfPoint) {
|
||||
chunks.push(remaining.slice(0, lastNewline + 1));
|
||||
remaining = remaining.slice(lastNewline + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try last space in the latter half
|
||||
const lastSpace = window.lastIndexOf(' ', limit - 1);
|
||||
if (lastSpace >= halfPoint) {
|
||||
chunks.push(remaining.slice(0, lastSpace + 1));
|
||||
remaining = remaining.slice(lastSpace + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hard cut at limit
|
||||
chunks.push(remaining.slice(0, limit));
|
||||
remaining = remaining.slice(limit);
|
||||
}
|
||||
|
||||
if (remaining.length > 0) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
205
src/channels/discord.test.ts
Normal file
205
src/channels/discord.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { DiscordChannel, stripMentionAndPrependTrigger, describeAttachments, prependReplyContext } from './discord.js';
|
||||
|
||||
// Feature: nanoclaw-go-app, Property 1: JID Ownership Correctness
|
||||
// **Validates: Requirements 1.7**
|
||||
|
||||
describe('DiscordChannel', () => {
|
||||
const dummyOpts = {
|
||||
onMessage: () => {},
|
||||
onChatMetadata: () => {},
|
||||
registeredGroups: () => ({}),
|
||||
};
|
||||
|
||||
const channel = new DiscordChannel('dummy-token', dummyOpts);
|
||||
|
||||
describe('ownsJid - Property 1: JID Ownership Correctness', () => {
|
||||
it('property: returns true for any string starting with dc:', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (suffix) => {
|
||||
const jid = `dc:${suffix}`;
|
||||
expect(channel.ownsJid(jid)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: returns false for any string not starting with dc:', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string().filter((s) => !s.startsWith('dc:')),
|
||||
(jid) => {
|
||||
expect(channel.ownsJid(jid)).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Feature: nanoclaw-go-app, Property 2: Mention Stripping and Trigger Prepending
|
||||
// **Validates: Requirements 1.3**
|
||||
describe('stripMentionAndPrependTrigger - Property 2: Mention Stripping and Trigger Prepending', () => {
|
||||
const ASSISTANT_NAME = 'Andy';
|
||||
const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
|
||||
// Arbitrary generator for a numeric-like bot ID (Discord snowflake IDs are numeric strings)
|
||||
const botIdArb = fc.nat({ max: 999999999999999 }).map(String);
|
||||
|
||||
// Generator for arbitrary message text that does NOT contain the mention pattern itself
|
||||
// (to avoid accidental nested mentions in the generated text)
|
||||
const messageTextArb = fc.string({ minLength: 0, maxLength: 200 }).filter(
|
||||
(s) => !/<@!?\d+>/.test(s),
|
||||
);
|
||||
|
||||
it('property: mention is removed and output starts with @AssistantName', () => {
|
||||
fc.assert(
|
||||
fc.property(botIdArb, messageTextArb, (botId, text) => {
|
||||
// Build input with a <@botId> mention
|
||||
const input = `<@${botId}> ${text}`;
|
||||
const result = stripMentionAndPrependTrigger(input, botId, ASSISTANT_NAME, TRIGGER_PATTERN);
|
||||
|
||||
// Mention markup must be removed
|
||||
expect(result).not.toContain(`<@${botId}>`);
|
||||
expect(result).not.toContain(`<@!${botId}>`);
|
||||
|
||||
// Output must start with @AssistantName
|
||||
expect(result).toMatch(TRIGGER_PATTERN);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: nickname-style mention <@!botId> is also removed and output starts with @AssistantName', () => {
|
||||
fc.assert(
|
||||
fc.property(botIdArb, messageTextArb, (botId, text) => {
|
||||
// Build input with a <@!botId> nickname mention
|
||||
const input = `<@!${botId}> ${text}`;
|
||||
const result = stripMentionAndPrependTrigger(input, botId, ASSISTANT_NAME, TRIGGER_PATTERN);
|
||||
|
||||
// Mention markup must be removed
|
||||
expect(result).not.toContain(`<@${botId}>`);
|
||||
expect(result).not.toContain(`<@!${botId}>`);
|
||||
|
||||
// Output must start with @AssistantName
|
||||
expect(result).toMatch(TRIGGER_PATTERN);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: text already starting with trigger pattern does not get double-prepended', () => {
|
||||
fc.assert(
|
||||
fc.property(botIdArb, messageTextArb, (botId, text) => {
|
||||
// Build input where text already has the trigger
|
||||
const input = `<@${botId}> @${ASSISTANT_NAME} ${text}`;
|
||||
const result = stripMentionAndPrependTrigger(input, botId, ASSISTANT_NAME, TRIGGER_PATTERN);
|
||||
|
||||
// Should not have double @AssistantName
|
||||
const triggerStr = `@${ASSISTANT_NAME}`;
|
||||
const firstIdx = result.indexOf(triggerStr);
|
||||
const secondIdx = result.indexOf(triggerStr, firstIdx + triggerStr.length);
|
||||
expect(secondIdx).toBe(-1);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Feature: nanoclaw-go-app, Property 3: Attachment Type Tagging
|
||||
// **Validates: Requirements 1.4**
|
||||
describe('describeAttachments - Property 3: Attachment Type Tagging', () => {
|
||||
// Content type prefix to expected tag mapping
|
||||
const typeMap: Array<{ prefix: string; tag: string }> = [
|
||||
{ prefix: 'image/', tag: 'Image' },
|
||||
{ prefix: 'video/', tag: 'Video' },
|
||||
{ prefix: 'audio/', tag: 'Audio' },
|
||||
];
|
||||
|
||||
// Generator for a known content type category
|
||||
const knownContentTypeArb = fc.oneof(
|
||||
fc.constant('image/').chain((p) => fc.string({ minLength: 1, maxLength: 20 }).map((s) => p + s.replace(/[\n\r]/g, ''))),
|
||||
fc.constant('video/').chain((p) => fc.string({ minLength: 1, maxLength: 20 }).map((s) => p + s.replace(/[\n\r]/g, ''))),
|
||||
fc.constant('audio/').chain((p) => fc.string({ minLength: 1, maxLength: 20 }).map((s) => p + s.replace(/[\n\r]/g, ''))),
|
||||
);
|
||||
|
||||
// Generator for a content type that doesn't match any known prefix
|
||||
const unknownContentTypeArb = fc
|
||||
.string({ minLength: 0, maxLength: 50 })
|
||||
.filter((s) => !s.startsWith('image/') && !s.startsWith('video/') && !s.startsWith('audio/'));
|
||||
|
||||
// Generator for a non-empty attachment name
|
||||
const nameArb = fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0);
|
||||
|
||||
// Generator for a single attachment with a known content type
|
||||
const knownAttachmentArb = fc.record({
|
||||
contentType: knownContentTypeArb,
|
||||
name: nameArb,
|
||||
});
|
||||
|
||||
// Generator for a single attachment with an unknown content type
|
||||
const unknownAttachmentArb = fc.record({
|
||||
contentType: unknownContentTypeArb,
|
||||
name: nameArb,
|
||||
});
|
||||
|
||||
// Generator for an arbitrary attachment (known or unknown type)
|
||||
const attachmentArb = fc.oneof(knownAttachmentArb, unknownAttachmentArb);
|
||||
|
||||
it('property: each attachment produces a correctly tagged bracketed description', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(attachmentArb, { minLength: 1, maxLength: 10 }), (attachments) => {
|
||||
const result = describeAttachments(attachments);
|
||||
|
||||
expect(result).toHaveLength(attachments.length);
|
||||
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const att = attachments[i];
|
||||
const desc = result[i];
|
||||
|
||||
// Determine expected tag
|
||||
const matched = typeMap.find((t) => att.contentType.startsWith(t.prefix));
|
||||
const expectedTag = matched ? matched.tag : 'File';
|
||||
|
||||
expect(desc).toBe(`[${expectedTag}: ${att.name}]`);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('property: empty attachment array returns empty result', () => {
|
||||
const result = describeAttachments([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Feature: nanoclaw-go-app, Property 4: Reply Context Prepending
|
||||
// **Validates: Requirements 1.5**
|
||||
describe('prependReplyContext - Property 4: Reply Context Prepending', () => {
|
||||
// Generator for author names: non-empty strings without brackets to avoid ambiguity
|
||||
const authorNameArb = fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0);
|
||||
|
||||
// Generator for arbitrary message content
|
||||
const contentArb = fc.string({ minLength: 0, maxLength: 500 });
|
||||
|
||||
it('property: output contains [Reply to A] for any author name A and any content', () => {
|
||||
fc.assert(
|
||||
fc.property(contentArb, authorNameArb, (content, authorName) => {
|
||||
const result = prependReplyContext(content, authorName);
|
||||
|
||||
// Output must contain the reply context tag
|
||||
expect(result).toContain(`[Reply to ${authorName}]`);
|
||||
|
||||
// Output must start with the reply context tag
|
||||
expect(result.startsWith(`[Reply to ${authorName}]`)).toBe(true);
|
||||
|
||||
// Original content must be preserved after the tag
|
||||
expect(result).toBe(`[Reply to ${authorName}] ${content}`);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
src/channels/discord.ts
Normal file
224
src/channels/discord.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Channel, OnChatMetadata, OnInboundMessage, RegisteredGroup } from '../types.js';
|
||||
import { chunkText } from './chunk-text.js';
|
||||
|
||||
export interface DiscordChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that strips bot mention markup from a message and prepends
|
||||
* the trigger pattern if not already present.
|
||||
*/
|
||||
export function stripMentionAndPrependTrigger(
|
||||
content: string,
|
||||
botId: string,
|
||||
assistantName: string,
|
||||
triggerPattern: RegExp,
|
||||
): string {
|
||||
let result = content.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
||||
if (!triggerPattern.test(result)) {
|
||||
result = `@${assistantName} ${result}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Pure function that maps an array of attachment-like objects to bracketed
|
||||
* type descriptions based on their content type.
|
||||
*/
|
||||
export function describeAttachments(
|
||||
attachments: Array<{ contentType: string; name: string }>,
|
||||
): string[] {
|
||||
return attachments.map((att) => {
|
||||
const contentType = att.contentType || '';
|
||||
if (contentType.startsWith('image/')) return `[Image: ${att.name || 'image'}]`;
|
||||
else if (contentType.startsWith('video/')) return `[Video: ${att.name || 'video'}]`;
|
||||
else if (contentType.startsWith('audio/')) return `[Audio: ${att.name || 'audio'}]`;
|
||||
else return `[File: ${att.name || 'file'}]`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that prepends reply context with the original author name
|
||||
* to the message content.
|
||||
*/
|
||||
export function prependReplyContext(content: string, replyAuthorName: string): string {
|
||||
return `[Reply to ${replyAuthorName}] ${content}`;
|
||||
}
|
||||
|
||||
export class DiscordChannel implements Channel {
|
||||
name = 'discord';
|
||||
private client: Client | null = null;
|
||||
private opts: DiscordChannelOpts;
|
||||
private botToken: string;
|
||||
|
||||
constructor(botToken: string, opts: DiscordChannelOpts) {
|
||||
this.botToken = botToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
});
|
||||
|
||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
const channelId = message.channelId;
|
||||
const chatJid = `dc:${channelId}`;
|
||||
let content = message.content;
|
||||
const timestamp = message.createdAt.toISOString();
|
||||
const senderName =
|
||||
message.member?.displayName || message.author.displayName || message.author.username;
|
||||
const sender = message.author.id;
|
||||
const msgId = message.id;
|
||||
|
||||
// Determine chat name from guild channel or DM
|
||||
let chatName: string;
|
||||
if (message.guild) {
|
||||
const textChannel = message.channel as TextChannel;
|
||||
chatName = `${message.guild.name} #${textChannel.name}`;
|
||||
} else {
|
||||
chatName = senderName;
|
||||
}
|
||||
|
||||
// Strip @mention and prepend trigger pattern if bot is mentioned
|
||||
if (this.client?.user) {
|
||||
const botId = this.client.user.id;
|
||||
const isBotMentioned =
|
||||
message.mentions.users.has(botId) ||
|
||||
content.includes(`<@${botId}>`) ||
|
||||
content.includes(`<@!${botId}>`);
|
||||
|
||||
if (isBotMentioned) {
|
||||
content = stripMentionAndPrependTrigger(content, botId, ASSISTANT_NAME, TRIGGER_PATTERN);
|
||||
}
|
||||
}
|
||||
|
||||
// Append attachment type descriptions
|
||||
if (message.attachments.size > 0) {
|
||||
const atts = [...message.attachments.values()].map((att) => ({
|
||||
contentType: att.contentType || '',
|
||||
name: att.name || '',
|
||||
}));
|
||||
const attachmentDescriptions = describeAttachments(atts);
|
||||
content = content
|
||||
? `${content}\n${attachmentDescriptions.join('\n')}`
|
||||
: attachmentDescriptions.join('\n');
|
||||
}
|
||||
|
||||
// Prepend reply context
|
||||
if (message.reference?.messageId) {
|
||||
try {
|
||||
const repliedTo = await message.channel.messages.fetch(message.reference.messageId);
|
||||
const replyAuthor =
|
||||
repliedTo.member?.displayName ||
|
||||
repliedTo.author.displayName ||
|
||||
repliedTo.author.username;
|
||||
content = prependReplyContext(content, replyAuthor);
|
||||
} catch {
|
||||
/* Referenced message may have been deleted */
|
||||
}
|
||||
}
|
||||
|
||||
this.opts.onChatMetadata(chatJid, timestamp, chatName);
|
||||
|
||||
const group = this.opts.registeredGroups()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug({ chatJid, chatName }, 'Message from unregistered Discord channel');
|
||||
return;
|
||||
}
|
||||
|
||||
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 }, 'Discord message stored');
|
||||
});
|
||||
|
||||
this.client.on(Events.Error, (err) => {
|
||||
logger.error({ err: err.message }, 'Discord client error');
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.client!.once(Events.ClientReady, (readyClient) => {
|
||||
logger.info(
|
||||
{ username: readyClient.user.tag, id: readyClient.user.id },
|
||||
'Discord bot connected',
|
||||
);
|
||||
console.log(`\n Discord bot: ${readyClient.user.tag}`);
|
||||
resolve();
|
||||
});
|
||||
this.client!.login(this.botToken);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.warn('Discord client not initialized');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (!channel || !('send' in channel)) {
|
||||
logger.warn({ jid }, 'Discord channel not found or not text-based');
|
||||
return;
|
||||
}
|
||||
const textChannel = channel as TextChannel;
|
||||
const chunks = chunkText(text, 2000);
|
||||
for (const chunk of chunks) {
|
||||
await textChannel.send(chunk);
|
||||
}
|
||||
logger.info({ jid, length: text.length }, 'Discord message sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send Discord message');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && this.client.isReady();
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('dc:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.destroy();
|
||||
this.client = null;
|
||||
logger.info('Discord bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
if (!this.client || !isTyping) return;
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (channel && 'sendTyping' in channel) {
|
||||
await (channel as TextChannel).sendTyping();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,18 @@ import { readEnvFile } from './env.js';
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'DISCORD_BOT_TOKEN',
|
||||
'DISCORD_ONLY',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
export const DISCORD_BOT_TOKEN =
|
||||
process.env.DISCORD_BOT_TOKEN || envConfig.DISCORD_BOT_TOKEN || '';
|
||||
export const DISCORD_ONLY =
|
||||
(process.env.DISCORD_ONLY || envConfig.DISCORD_ONLY) === 'true';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
@@ -57,6 +63,7 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(
|
||||
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
|
||||
'i',
|
||||
|
||||
22
src/index.ts
22
src/index.ts
@@ -5,11 +5,14 @@ import path from 'path';
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
DISCORD_BOT_TOKEN,
|
||||
DISCORD_ONLY,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
POLL_INTERVAL,
|
||||
TRIGGER_PATTERN,
|
||||
} from './config.js';
|
||||
import { DiscordChannel } from './channels/discord.js';
|
||||
import { WhatsAppChannel } from './channels/whatsapp.js';
|
||||
import {
|
||||
ContainerOutput,
|
||||
@@ -485,9 +488,17 @@ async function main(): Promise<void> {
|
||||
};
|
||||
|
||||
// Create and connect channels
|
||||
whatsapp = new WhatsAppChannel(channelOpts);
|
||||
channels.push(whatsapp);
|
||||
await whatsapp.connect();
|
||||
if (DISCORD_BOT_TOKEN) {
|
||||
const discord = new DiscordChannel(DISCORD_BOT_TOKEN, channelOpts);
|
||||
channels.push(discord);
|
||||
await discord.connect();
|
||||
}
|
||||
|
||||
if (!DISCORD_ONLY) {
|
||||
whatsapp = new WhatsAppChannel(channelOpts);
|
||||
channels.push(whatsapp);
|
||||
await whatsapp.connect();
|
||||
}
|
||||
|
||||
// Start subsystems (independently of connection handler)
|
||||
startSchedulerLoop({
|
||||
@@ -497,10 +508,7 @@ async function main(): Promise<void> {
|
||||
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
||||
sendMessage: async (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) {
|
||||
console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
|
||||
return;
|
||||
}
|
||||
if (!channel) return;
|
||||
const text = formatOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
|
||||
111
src/opencode/live-sessions.test.ts
Normal file
111
src/opencode/live-sessions.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LiveSessionManager } from './live-sessions.js';
|
||||
|
||||
describe('LiveSessionManager', () => {
|
||||
let manager: LiveSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
manager = new LiveSessionManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getOrCreate', () => {
|
||||
it('creates a new session when none exists', () => {
|
||||
const session = manager.getOrCreate('conv-1');
|
||||
expect(session.conversationId).toBe('conv-1');
|
||||
expect(session.messageCount).toBe(0);
|
||||
expect(session.createdAt).toBeGreaterThan(0);
|
||||
expect(session.lastActive).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns existing session on second call', () => {
|
||||
const first = manager.getOrCreate('conv-1');
|
||||
first.messageCount = 5;
|
||||
const second = manager.getOrCreate('conv-1');
|
||||
expect(second).toBe(first);
|
||||
expect(second.messageCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns undefined for unknown conversation', () => {
|
||||
expect(manager.get('unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns session and updates lastActive', () => {
|
||||
manager.getOrCreate('conv-1');
|
||||
vi.advanceTimersByTime(1000);
|
||||
const session = manager.get('conv-1');
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.lastActive).toBe(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes an existing session', () => {
|
||||
manager.getOrCreate('conv-1');
|
||||
manager.remove('conv-1');
|
||||
expect(manager.get('conv-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing for unknown conversation', () => {
|
||||
manager.remove('unknown'); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('listActive', () => {
|
||||
it('returns empty array when no sessions', () => {
|
||||
expect(manager.listActive()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all active sessions', () => {
|
||||
manager.getOrCreate('conv-1');
|
||||
manager.getOrCreate('conv-2');
|
||||
expect(manager.listActive()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup loop', () => {
|
||||
it('removes idle sessions after timeout', () => {
|
||||
const mgr = new LiveSessionManager(5000); // 5s timeout
|
||||
mgr.start();
|
||||
|
||||
mgr.getOrCreate('conv-1');
|
||||
// Advance past idle timeout AND past the 60s cleanup interval tick
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(mgr.listActive()).toHaveLength(0);
|
||||
mgr.stop();
|
||||
});
|
||||
|
||||
it('keeps active sessions alive', () => {
|
||||
const mgr = new LiveSessionManager(120_000); // 2 min timeout
|
||||
mgr.start();
|
||||
|
||||
mgr.getOrCreate('conv-1');
|
||||
// Advance 59s (just before cleanup tick), touch the session
|
||||
vi.advanceTimersByTime(59_000);
|
||||
mgr.get('conv-1'); // touch — resets lastActive
|
||||
|
||||
// Advance another 60s to trigger next cleanup tick
|
||||
// Session was touched 60s ago, timeout is 120s — should survive
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(mgr.listActive()).toHaveLength(1);
|
||||
mgr.stop();
|
||||
});
|
||||
|
||||
it('stop clears the interval', () => {
|
||||
manager.start();
|
||||
manager.stop();
|
||||
// Starting and stopping again should not throw
|
||||
manager.start();
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/opencode/live-sessions.ts
Normal file
69
src/opencode/live-sessions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { LiveSession } from './types.js';
|
||||
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000; // 30 minutes
|
||||
const CLEANUP_INTERVAL_MS = 60_000; // 60 seconds
|
||||
|
||||
export class LiveSessionManager {
|
||||
private sessions = new Map<string, LiveSession>();
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private idleTimeoutMs: number;
|
||||
|
||||
constructor(idleTimeoutMs?: number) {
|
||||
this.idleTimeoutMs = idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.cleanupTimer) return;
|
||||
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
||||
// Allow the Node process to exit even if the timer is still running
|
||||
if (this.cleanupTimer.unref) {
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreate(conversationId: string): LiveSession {
|
||||
const existing = this.sessions.get(conversationId);
|
||||
if (existing) return existing;
|
||||
|
||||
const session: LiveSession = {
|
||||
conversationId,
|
||||
createdAt: Date.now(),
|
||||
lastActive: Date.now(),
|
||||
messageCount: 0,
|
||||
};
|
||||
this.sessions.set(conversationId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(conversationId: string): LiveSession | undefined {
|
||||
const session = this.sessions.get(conversationId);
|
||||
if (session) {
|
||||
session.lastActive = Date.now();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
remove(conversationId: string): void {
|
||||
this.sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
listActive(): LiveSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (now - session.lastActive > this.idleTimeoutMs) {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
src/opencode/runtime.ts
Normal file
269
src/opencode/runtime.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { AgentResponse, OpenCodeConfig } from './types.js';
|
||||
import { SessionStore } from './session-store.js';
|
||||
import { LiveSessionManager } from './live-sessions.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const RATE_LIMIT_INDICATORS = [
|
||||
'rate limit',
|
||||
'rate_limit',
|
||||
'too many requests',
|
||||
'429',
|
||||
'throttl',
|
||||
];
|
||||
|
||||
function isRateLimited(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return RATE_LIMIT_INDICATORS.some((indicator) => lower.includes(indicator));
|
||||
}
|
||||
|
||||
function resolveConfig(partial?: Partial<OpenCodeConfig>): OpenCodeConfig {
|
||||
const envMode = process.env.OPENCODE_MODE?.toLowerCase() === 'sdk' ? 'sdk' : 'cli';
|
||||
const envModel = process.env.OPENCODE_MODEL || undefined;
|
||||
const envTimeout = process.env.OPENCODE_TIMEOUT
|
||||
? parseInt(process.env.OPENCODE_TIMEOUT, 10) * 1000
|
||||
: 120_000;
|
||||
const envTtl = process.env.OPENCODE_SESSION_TTL_HOURS
|
||||
? parseInt(process.env.OPENCODE_SESSION_TTL_HOURS, 10)
|
||||
: 24;
|
||||
|
||||
return {
|
||||
mode: partial?.mode ?? envMode,
|
||||
command: partial?.command ?? 'opencode',
|
||||
serverUrl: partial?.serverUrl ?? 'http://localhost:3000',
|
||||
serverPassword: partial?.serverPassword,
|
||||
timeoutMs: partial?.timeoutMs ?? envTimeout,
|
||||
model: partial?.model ?? envModel,
|
||||
provider: partial?.provider,
|
||||
systemPrompt: partial?.systemPrompt,
|
||||
workspaceDir: partial?.workspaceDir,
|
||||
format: partial?.format ?? 'json',
|
||||
sessionTtlHours: partial?.sessionTtlHours ?? envTtl,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeRuntime {
|
||||
private config: OpenCodeConfig;
|
||||
private sessionStore: SessionStore;
|
||||
private liveSessions: LiveSessionManager;
|
||||
|
||||
constructor(config?: Partial<OpenCodeConfig>) {
|
||||
this.config = resolveConfig(config);
|
||||
this.sessionStore = new SessionStore();
|
||||
this.liveSessions = new LiveSessionManager();
|
||||
this.liveSessions.start();
|
||||
}
|
||||
|
||||
async chat(
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
systemPrompt?: string,
|
||||
): Promise<AgentResponse> {
|
||||
if (!message || !message.trim()) {
|
||||
return {
|
||||
text: '',
|
||||
durationMs: 0,
|
||||
rateLimited: false,
|
||||
error: 'Empty message',
|
||||
};
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Resolve session ID from conversation ID if provided
|
||||
let sessionId: string | undefined;
|
||||
if (conversationId) {
|
||||
const live = this.liveSessions.getOrCreate(conversationId);
|
||||
live.lastActive = Date.now();
|
||||
live.messageCount++;
|
||||
|
||||
const stored = this.sessionStore.get(conversationId);
|
||||
if (stored) {
|
||||
sessionId = stored;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = systemPrompt ?? this.config.systemPrompt;
|
||||
|
||||
const response =
|
||||
this.config.mode === 'sdk'
|
||||
? await this.chatSdk(message, sessionId, prompt)
|
||||
: await this.chatCli(message, sessionId, prompt);
|
||||
|
||||
// Persist session mapping if we got a new session ID back
|
||||
if (conversationId && response.sessionId) {
|
||||
this.sessionStore.set(conversationId, response.sessionId);
|
||||
const live = this.liveSessions.get(conversationId);
|
||||
if (live) {
|
||||
live.sessionId = response.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
text: '',
|
||||
durationMs: Date.now() - start,
|
||||
rateLimited: isRateLimited(errorMsg),
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendFollowup(
|
||||
message: string,
|
||||
conversationId: string,
|
||||
systemPrompt?: string,
|
||||
): Promise<AgentResponse> {
|
||||
return this.chat(message, conversationId, systemPrompt);
|
||||
}
|
||||
|
||||
closeSession(conversationId: string): boolean {
|
||||
const existed = this.sessionStore.get(conversationId) !== null;
|
||||
this.sessionStore.remove(conversationId);
|
||||
this.liveSessions.remove(conversationId);
|
||||
return existed;
|
||||
}
|
||||
|
||||
getStatus(): Record<string, unknown> {
|
||||
return {
|
||||
mode: this.config.mode,
|
||||
command: this.config.command,
|
||||
serverUrl: this.config.serverUrl,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
model: this.config.model,
|
||||
activeSessions: this.liveSessions.listActive().length,
|
||||
storedSessions: this.sessionStore.count(),
|
||||
};
|
||||
}
|
||||
|
||||
cleanupSessions(): number {
|
||||
return this.sessionStore.cleanup(this.config.sessionTtlHours);
|
||||
}
|
||||
|
||||
private async chatCli(
|
||||
message: string,
|
||||
sessionId?: string,
|
||||
systemPrompt?: string,
|
||||
): Promise<AgentResponse> {
|
||||
const args = ['run'];
|
||||
|
||||
if (this.config.model) {
|
||||
args.push('--model', this.config.model);
|
||||
}
|
||||
if (this.config.provider) {
|
||||
args.push('--provider', this.config.provider);
|
||||
}
|
||||
if (sessionId) {
|
||||
args.push('--session', sessionId);
|
||||
}
|
||||
if (systemPrompt) {
|
||||
args.push('--system-prompt', systemPrompt);
|
||||
}
|
||||
if (this.config.format === 'json') {
|
||||
args.push('--output', 'json');
|
||||
}
|
||||
|
||||
args.push(message);
|
||||
|
||||
const { stdout } = await execFileAsync(this.config.command, args, {
|
||||
timeout: this.config.timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
cwd: this.config.workspaceDir,
|
||||
});
|
||||
|
||||
return this.parseCliOutput(stdout);
|
||||
}
|
||||
|
||||
private parseCliOutput(stdout: string): AgentResponse {
|
||||
const trimmed = stdout.trim();
|
||||
|
||||
// Try JSON parse first
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const text = parsed.response ?? parsed.text ?? parsed.content ?? trimmed;
|
||||
return {
|
||||
text: typeof text === 'string' ? text : JSON.stringify(text),
|
||||
sessionId: parsed.session_id ?? parsed.sessionId,
|
||||
model: parsed.model,
|
||||
durationMs: 0,
|
||||
rateLimited: false,
|
||||
};
|
||||
} catch {
|
||||
// Fall back to plain text
|
||||
return {
|
||||
text: trimmed,
|
||||
durationMs: 0,
|
||||
rateLimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async chatSdk(
|
||||
message: string,
|
||||
sessionId?: string,
|
||||
systemPrompt?: string,
|
||||
): Promise<AgentResponse> {
|
||||
const url = `${this.config.serverUrl}/api/chat`;
|
||||
|
||||
const body: Record<string, unknown> = { message };
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
if (this.config.model) body.model = this.config.model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.config.serverPassword) {
|
||||
headers['Authorization'] = `Bearer ${this.config.serverPassword}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
const rateLimited = res.status === 429 || isRateLimited(errText);
|
||||
return {
|
||||
text: '',
|
||||
durationMs: 0,
|
||||
rateLimited,
|
||||
error: `HTTP ${res.status}: ${errText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
const text =
|
||||
(data.response as string) ??
|
||||
(data.text as string) ??
|
||||
(data.content as string) ??
|
||||
'';
|
||||
|
||||
return {
|
||||
text,
|
||||
sessionId: (data.session_id as string) ?? (data.sessionId as string),
|
||||
model: data.model as string | undefined,
|
||||
durationMs: 0,
|
||||
rateLimited: false,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/opencode/session-store.ts
Normal file
68
src/opencode/session-store.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export class SessionStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(dbPath: string = ':memory:') {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS opencode_sessions (
|
||||
external_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
source TEXT DEFAULT '',
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
get(externalId: string): string | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT session_id FROM opencode_sessions WHERE external_id = ?')
|
||||
.get(externalId) as { session_id: string } | undefined;
|
||||
return row?.session_id ?? null;
|
||||
}
|
||||
|
||||
set(externalId: string, sessionId: string, source: string = ''): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO opencode_sessions (external_id, session_id, source, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
)
|
||||
.run(externalId, sessionId, source);
|
||||
}
|
||||
|
||||
remove(externalId: string): void {
|
||||
this.db
|
||||
.prepare('DELETE FROM opencode_sessions WHERE external_id = ?')
|
||||
.run(externalId);
|
||||
}
|
||||
|
||||
cleanup(ttlHours: number = 24): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`DELETE FROM opencode_sessions
|
||||
WHERE updated_at < datetime('now', ? || ' hours')`,
|
||||
)
|
||||
.run(`-${ttlHours}`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
listAll(): Array<{ externalId: string; sessionId: string; source: string; updatedAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare('SELECT external_id, session_id, source, updated_at FROM opencode_sessions ORDER BY updated_at DESC')
|
||||
.all() as Array<{ external_id: string; session_id: string; source: string; updated_at: string }>;
|
||||
return rows.map((r) => ({
|
||||
externalId: r.external_id,
|
||||
sessionId: r.session_id,
|
||||
source: r.source,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM opencode_sessions')
|
||||
.get() as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
}
|
||||
32
src/opencode/types.ts
Normal file
32
src/opencode/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type RuntimeMode = 'cli' | 'sdk';
|
||||
|
||||
export interface OpenCodeConfig {
|
||||
mode: RuntimeMode;
|
||||
command: string;
|
||||
serverUrl: string;
|
||||
serverPassword?: string;
|
||||
timeoutMs: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
systemPrompt?: string;
|
||||
workspaceDir?: string;
|
||||
format: 'json' | 'default';
|
||||
sessionTtlHours: number;
|
||||
}
|
||||
|
||||
export interface AgentResponse {
|
||||
text: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
rateLimited: boolean;
|
||||
}
|
||||
|
||||
export interface LiveSession {
|
||||
conversationId: string;
|
||||
sessionId?: string;
|
||||
createdAt: number;
|
||||
lastActive: number;
|
||||
messageCount: number;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
|
||||
import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
|
||||
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
|
||||
import { findChannel } from './router.js';
|
||||
import { Channel } from './types.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
@@ -18,6 +21,11 @@ describe('JID ownership patterns', () => {
|
||||
expect(jid.endsWith('@g.us')).toBe(true);
|
||||
});
|
||||
|
||||
it('Discord JID: starts with dc:', () => {
|
||||
const jid = 'dc:1234567890123456';
|
||||
expect(jid.startsWith('dc:')).toBe(true);
|
||||
});
|
||||
|
||||
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
|
||||
const jid = '12345678@s.whatsapp.net';
|
||||
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
||||
@@ -80,6 +88,36 @@ describe('getAvailableGroups', () => {
|
||||
expect(groups[2].jid).toBe('old@g.us');
|
||||
});
|
||||
|
||||
it('includes Discord channel JIDs', () => {
|
||||
storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'Discord Channel', 'discord', 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('dc:1234567890123456');
|
||||
});
|
||||
|
||||
it('marks registered Discord channels correctly', () => {
|
||||
storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'DC Registered', 'discord', true);
|
||||
storeChatMetadata('dc:9999999999999999', '2024-01-01T00:00:02.000Z', 'DC Unregistered', 'discord', true);
|
||||
|
||||
_setRegisteredGroups({
|
||||
'dc:1234567890123456': {
|
||||
name: 'DC Registered',
|
||||
folder: 'dc-registered',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
const dcReg = groups.find((g) => g.jid === 'dc:1234567890123456');
|
||||
const dcUnreg = groups.find((g) => g.jid === 'dc:9999999999999999');
|
||||
|
||||
expect(dcReg?.isRegistered).toBe(true);
|
||||
expect(dcUnreg?.isRegistered).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes non-group chats regardless of JID format', () => {
|
||||
// Unknown JID format stored without is_group should not appear
|
||||
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
|
||||
@@ -97,4 +135,112 @@ describe('getAvailableGroups', () => {
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('mixes WhatsApp and Discord chats ordered by activity', () => {
|
||||
storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
|
||||
storeChatMetadata('dc:555', '2024-01-01T00:00:03.000Z', 'Discord', 'discord', 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('dc:555');
|
||||
expect(groups[1].jid).toBe('wa2@g.us');
|
||||
expect(groups[2].jid).toBe('wa@g.us');
|
||||
});
|
||||
});
|
||||
|
||||
// --- findChannel property-based tests ---
|
||||
// Feature: nanoclaw-go-app, Property 5: Channel Routing by JID
|
||||
|
||||
/** Create a mock channel with prefix-based ownsJid */
|
||||
const makeChannel = (prefix: string, name: string): Channel => ({
|
||||
name,
|
||||
ownsJid: (jid: string) => jid.startsWith(prefix),
|
||||
connect: async () => {},
|
||||
sendMessage: async () => {},
|
||||
isConnected: () => true,
|
||||
disconnect: async () => {},
|
||||
});
|
||||
|
||||
describe('findChannel - Property 5: Channel Routing by JID', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.5**
|
||||
*
|
||||
* For any JID starting with a known prefix, findChannel returns the correct channel.
|
||||
* For any JID not matching any prefix, findChannel returns undefined.
|
||||
*/
|
||||
it('returns the channel whose ownsJid returns true for the given JID', () => {
|
||||
// Use non-overlapping prefixes so exactly one channel matches
|
||||
const prefixes = ['dc:', 'wa:', 'tg:', 'sl:'];
|
||||
const channels = prefixes.map((p, i) => makeChannel(p, `channel-${i}`));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: prefixes.length - 1 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
(prefixIdx, suffix) => {
|
||||
const jid = prefixes[prefixIdx] + suffix;
|
||||
const result = findChannel(channels, jid);
|
||||
// Exactly one channel should match — the one with the chosen prefix
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toBe(`channel-${prefixIdx}`);
|
||||
expect(result!.ownsJid(jid)).toBe(true);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when no channel owns the JID', () => {
|
||||
const channels = [
|
||||
makeChannel('dc:', 'discord'),
|
||||
makeChannel('wa:', 'whatsapp'),
|
||||
];
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Generate strings that don't start with any known prefix
|
||||
fc.string({ minLength: 1 }).filter((s) => !s.startsWith('dc:') && !s.startsWith('wa:')),
|
||||
(jid) => {
|
||||
const result = findChannel(channels, jid);
|
||||
expect(result).toBeUndefined();
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the first matching channel when multiple could match', () => {
|
||||
// Edge case: overlapping prefixes — findChannel returns the first match
|
||||
const ch1 = makeChannel('dc:', 'discord-first');
|
||||
const ch2 = makeChannel('dc:', 'discord-second');
|
||||
const channels = [ch1, ch2];
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 0 }),
|
||||
(suffix) => {
|
||||
const jid = `dc:${suffix}`;
|
||||
const result = findChannel(channels, jid);
|
||||
expect(result).toBeDefined();
|
||||
// Should always be the first channel in the array
|
||||
expect(result!.name).toBe('discord-first');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for empty channel list', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string(),
|
||||
(jid) => {
|
||||
const result = findChannel([], jid);
|
||||
expect(result).toBeUndefined();
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user