From 0210aa9ef169401aa5c8d85e002c2e8976f69253 Mon Sep 17 00:00:00 2001
From: Gabi Simons
Date: Tue, 3 Mar 2026 00:35:45 +0200
Subject: [PATCH] refactor: implement multi-channel architecture (#500)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: implement channel architecture and dynamic setup
- Introduced ChannelRegistry for dynamic channel loading
- Decoupled WhatsApp from core index.ts and config.ts
- Updated setup wizard to support ENABLED_CHANNELS selection
- Refactored IPC and group registration to be channel-aware
- Verified with 359 passing tests and clean typecheck
* style: fix formatting in config.ts to pass CI
* refactor(setup): full platform-agnostic transformation
- Harmonized all instructional text and help prompts
- Implemented conditional guards for WhatsApp-specific steps
- Normalized CLI terminology across all 4 initial channels
- Unified troubleshooting and verification logic
- Verified 369 tests pass with clean typecheck
* feat(skills): transform WhatsApp into a pluggable skill
- Created .claude/skills/add-whatsapp with full 5-phase interactive setup
- Fixed TS7006 'implicit any' error in IpcDeps
- Added auto-creation of STORE_DIR to prevent crashes on fresh installs
- Verified with 369 passing tests and clean typecheck
* refactor(skills): move WhatsApp from core to pluggable skill
- Move src/channels/whatsapp.ts to add-whatsapp skill add/ folder
- Move src/channels/whatsapp.test.ts to skill add/ folder
- Move src/whatsapp-auth.ts to skill add/ folder
- Create modify/ for barrel file (src/channels/index.ts)
- Create tests/ with skill package validation test
- Update manifest with adds/modifies lists
- Remove WhatsApp deps from core package.json (now skill-managed)
- Remove WhatsApp-specific ghost language from types.ts
- Update SKILL.md to reflect skill-apply workflow
Co-Authored-By: Claude Opus 4.6
* refactor(skills): move setup/whatsapp-auth.ts into WhatsApp skill
The WhatsApp auth setup step is channel-specific — move it from core
to the add-whatsapp skill so core stays minimal.
Co-Authored-By: Claude Opus 4.6
* refactor(skills): convert Telegram skill to pluggable channel pattern
Replace the old direct-integration approach (modifying src/index.ts,
src/config.ts, src/routing.test.ts) with self-registration via the
channel registry, matching the WhatsApp skill pattern.
Co-Authored-By: Claude Opus 4.6
* fix(skills): fix add-whatsapp build failure and improve auth flow
- Add missing @types/qrcode-terminal to manifest npm_dependencies
(build failed after skill apply without it)
- Make QR-browser the recommended auth method (terminal QR too small,
pairing codes expire too fast)
- Remove "replace vs alongside" question — channels are additive
- Add pairing code retry guidance and QR-browser fallback
Co-Authored-By: Claude Opus 4.6
* fix: remove hardcoded WhatsApp default and stale Baileys comment
- ENABLED_CHANNELS now defaults to empty (fresh installs must configure
channels explicitly via /setup; existing installs already have .env)
- Remove Baileys-specific comment from storeMessageDirect() in db.ts
Co-Authored-By: Claude Opus 4.6
* refactor(skills): convert Discord, Slack, Gmail skills to pluggable channel pattern
All channel skills now use the same self-registration pattern:
- registerChannel() factory at module load time
- Barrel file append (src/channels/index.ts) instead of orchestrator modifications
- No more *_ONLY flags (DISCORD_ONLY, SLACK_ONLY) — use ENABLED_CHANNELS instead
- Removed ~2500 lines of old modify/ files (src/index.ts, src/config.ts, src/routing.test.ts)
Gmail retains its container-runner.ts and agent-runner modifications (MCP
mount + server config) since those are independent of channel wiring.
Co-Authored-By: Claude Opus 4.6
* refactor: use getRegisteredChannels instead of ENABLED_CHANNELS
Remove the ENABLED_CHANNELS env var entirely. The orchestrator now
iterates getRegisteredChannelNames() from the channel registry —
channels self-register via barrel imports and their factories return
null when credentials are missing, so unconfigured channels are
skipped automatically.
Deleted setup/channels.ts (and its tests) since its sole purpose was
writing ENABLED_CHANNELS to .env. Refactored verify, groups, and
environment setup steps to detect channels by credential presence
instead of reading ENABLED_CHANNELS.
Co-Authored-By: Claude Opus 4.6
* docs: add breaking change notice and whatsapp migration instructions
CHANGELOG.md documents the pluggable channel architecture shift and
provides migration steps for existing WhatsApp users.
CLAUDE.md updated: Quick Context reflects multi-channel architecture,
Key Files lists registry.ts instead of whatsapp.ts, and a new
Troubleshooting section directs users to /add-whatsapp if WhatsApp
stops connecting after upgrade.
Co-Authored-By: Claude Opus 4.6
* docs: rewrite READMEs for pluggable multi-channel architecture
Reflects the architectural shift from a hardcoded WhatsApp bot to a
pluggable channel platform. Adds upgrading notice, Mermaid architecture
diagram, CI/License/TypeScript/PRs badges, and clarifies that slash
commands run inside the Claude Code CLI.
Co-Authored-By: Claude Opus 4.6
* docs: move pluggable channel architecture details to SPEC.md
Revert READMEs to original tone with only two targeted changes:
- Add upgrading notice for WhatsApp breaking change
- Mention pluggable channels in "What It Supports"
Move Mermaid diagram, channel registry internals, factory pattern
explanation, and self-registration walkthrough into docs/SPEC.md.
Update stale WhatsApp-specific references in SPEC.md to be
channel-agnostic.
Co-Authored-By: Claude Opus 4.6
* docs: move upgrading notice to CHANGELOG, add changelog link
Remove the "Upgrading from Pre-Pluggable Versions" section from
README.md — breaking change details belong in the CHANGELOG. Add a
Changelog section linking to CHANGELOG.md.
Co-Authored-By: Claude Opus 4.6
* docs: expand CHANGELOG with full PR #500 changes
Cover all changes: channel registry, WhatsApp moved to skill, removed
core dependencies, all 5 skills simplified, orchestrator refactored,
setup decoupled. Use Claude Code CLI instructions for migration.
Co-Authored-By: Claude Opus 4.6
* chore: bump version to 1.2.0 for pluggable channel architecture
Minor version bump — new functionality (pluggable channels) with a
managed migration path for existing WhatsApp users. Update version
references in CHANGELOG and update skill.
Co-Authored-By: Claude Opus 4.6
* Fix skill application
* fix: use slotted barrel file to prevent channel merge conflicts
Pre-allocate a named comment slot for each channel in
src/channels/index.ts, separated by blank lines. Each skill's
modify file only touches its own slot, so three-way merges
never conflict when applying multiple channels.
Co-Authored-By: Claude Opus 4.6
* fix: resolve real chat ID during setup for token-based channels
Instead of registering with `pending@telegram` (which never matches
incoming messages), the setup skill now runs an inline bot that waits
for the user to send /chatid, capturing the real chat ID before
registration.
Co-Authored-By: Claude Opus 4.6
* fix: setup delegates to channel skills, fix group sync and Discord metadata
- Restructure setup SKILL.md to delegate channel setup to individual
channel skills (/add-whatsapp, /add-telegram, etc.) instead of
reimplementing auth/registration inline with broken placeholder JIDs
- Move channel selection to step 5 where it's immediately acted on
- Fix setup/groups.ts: write sync script to temp file instead of passing
via node -e which broke on shell escaping of newlines
- Fix Discord onChatMetadata missing channel and isGroup parameters
- Add .tmp-* to .gitignore for temp sync script cleanup
Co-Authored-By: Claude Opus 4.6
* fix: align add-whatsapp skill with main setup patterns
Add headless detection for auth method selection, structured inline
error handling, dedicated number DM flow, and reorder questions to
match main's trigger-first flow.
Co-Authored-By: Claude Opus 4.6
* fix: add missing auth script to package.json
The add-whatsapp skill adds src/whatsapp-auth.ts but doesn't add
the corresponding npm script. Setup and SKILL.md reference `npm run auth`
for WhatsApp QR terminal authentication.
Co-Authored-By: Claude Opus 4.6
* fix: update Discord skill tests to match onChatMetadata signature
The onChatMetadata callback now takes 5 arguments (jid, timestamp,
name, channel, isGroup) but the Discord skill tests only expected 3.
This caused skill application to roll back on test failure.
Co-Authored-By: Claude Opus 4.6
* docs: replace 'pluggable' jargon with clearer language
User-facing text now says "multi-channel" or describes what it does.
Developer-facing text uses "self-registering" or "channel registry".
Also removes extra badge row from README.
Co-Authored-By: Claude Opus 4.6
* docs: align Chinese README with English version
Remove extra badges, replace pluggable jargon, remove upgrade section
(now in CHANGELOG), add missing intro line and changelog section,
fix setup FAQ answer.
Co-Authored-By: Claude Opus 4.6
* fix: warn on installed-but-unconfigured channels instead of silent skip
Channels with missing credentials now emit WARN logs naming the exact
missing variable, so misconfigurations surface instead of being hidden.
Co-Authored-By: Claude Opus 4.6
* docs: simplify changelog to one-liner with compare link
Co-Authored-By: Claude Opus 4.6
* feat: add isMain flag and channel-prefixed group folders
Replace MAIN_GROUP_FOLDER constant with explicit isMain boolean on
RegisteredGroup. Group folders now use channel prefix convention
(e.g., whatsapp_main, telegram_family-chat) to prevent cross-channel
collisions.
- Add isMain to RegisteredGroup type and SQLite schema (with migration)
- Replace all folder-based main group checks with group.isMain
- Add --is-main flag to setup/register.ts
- Strip isMain from IPC payload (defense in depth)
- Update MCP tool description for channel-prefixed naming
- Update all channel SKILL.md files and documentation
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: gavrielc
Co-authored-by: Koshkoshinski
---
.claude/skills/add-discord/SKILL.md | 31 +-
.../add/src/channels/discord.test.ts | 14 +
.../add-discord/add/src/channels/discord.ts | 16 +-
.claude/skills/add-discord/manifest.yaml | 5 +-
.../add-discord/modify/src/channels/index.ts | 13 +
.../modify/src/channels/index.ts.intent.md | 7 +
.../skills/add-discord/modify/src/config.ts | 77 -
.../modify/src/config.ts.intent.md | 21 -
.../skills/add-discord/modify/src/index.ts | 509 ------
.../add-discord/modify/src/index.ts.intent.md | 43 -
.../add-discord/modify/src/routing.test.ts | 147 --
.../skills/add-discord/tests/discord.test.ts | 126 +-
.claude/skills/add-gmail/SKILL.md | 18 +-
.../add-gmail/add/src/channels/gmail.test.ts | 3 +
.../add-gmail/add/src/channels/gmail.ts | 17 +-
.claude/skills/add-gmail/manifest.yaml | 3 +-
.../add-gmail/modify/src/channels/index.ts | 13 +
.../modify/src/channels/index.ts.intent.md | 7 +
.claude/skills/add-gmail/modify/src/index.ts | 507 ------
.../add-gmail/modify/src/index.ts.intent.md | 40 -
.../add-gmail/modify/src/routing.test.ts | 119 --
.claude/skills/add-gmail/tests/gmail.test.ts | 108 +-
.claude/skills/add-slack/SKILL.md | 34 +-
.../add-slack/add/src/channels/slack.test.ts | 3 +
.../add-slack/add/src/channels/slack.ts | 10 +
.claude/skills/add-slack/manifest.yaml | 5 +-
.../add-slack/modify/src/channels/index.ts | 13 +
.../modify/src/channels/index.ts.intent.md | 7 +
.claude/skills/add-slack/modify/src/config.ts | 75 -
.../add-slack/modify/src/config.ts.intent.md | 21 -
.claude/skills/add-slack/modify/src/index.ts | 498 ------
.../add-slack/modify/src/index.ts.intent.md | 60 -
.../add-slack/modify/src/routing.test.ts | 161 --
.../modify/src/routing.test.ts.intent.md | 17 -
.claude/skills/add-slack/tests/slack.test.ts | 133 +-
.claude/skills/add-telegram/SKILL.md | 44 +-
.../add/src/channels/telegram.test.ts | 6 +
.../add-telegram/add/src/channels/telegram.ts | 13 +
.claude/skills/add-telegram/manifest.yaml | 5 +-
.../add-telegram/modify/src/channels/index.ts | 13 +
.../modify/src/channels/index.ts.intent.md | 7 +
.../skills/add-telegram/modify/src/config.ts | 77 -
.../modify/src/config.ts.intent.md | 21 -
.../skills/add-telegram/modify/src/index.ts | 509 ------
.../modify/src/index.ts.intent.md | 50 -
.../add-telegram/modify/src/routing.test.ts | 161 --
.../add-telegram/tests/telegram.test.ts | 111 +-
.claude/skills/add-whatsapp/SKILL.md | 345 ++++
.../add-whatsapp/add/setup}/whatsapp-auth.ts | 6 +-
.../add/src}/channels/whatsapp.test.ts | 0
.../add/src}/channels/whatsapp.ts | 7 +
.../add-whatsapp/add/src}/whatsapp-auth.ts | 0
.claude/skills/add-whatsapp/manifest.yaml | 23 +
.../skills/add-whatsapp/modify/setup/index.ts | 60 +
.../modify/setup/index.ts.intent.md | 1 +
.../add-whatsapp/modify/src/channels/index.ts | 13 +
.../modify/src/channels/index.ts.intent.md | 7 +
.../add-whatsapp/tests/whatsapp.test.ts | 70 +
.claude/skills/setup/SKILL.md | 85 +-
.gitignore | 3 +
CHANGELOG.md | 4 +
CLAUDE.md | 8 +-
README.md | 19 +-
README_zh.md | 44 +-
container/agent-runner/src/ipc-mcp-stdio.ts | 8 +-
docs/SPEC.md | 288 +++-
groups/main/CLAUDE.md | 30 +-
package-lock.json | 1423 +----------------
package.json | 8 +-
setup/environment.test.ts | 6 +-
setup/groups.ts | 42 +-
setup/index.ts | 1 -
setup/register.test.ts | 46 +-
setup/register.ts | 31 +-
setup/service.ts | 4 +-
setup/verify.ts | 40 +-
src/channels/index.ts | 12 +
src/channels/registry.test.ts | 42 +
src/channels/registry.ts | 28 +
src/config.ts | 1 -
src/db.test.ts | 36 +
src/db.ts | 24 +-
src/index.ts | 47 +-
src/ipc-auth.test.ts | 59 +-
src/ipc.ts | 20 +-
src/task-scheduler.ts | 9 +-
src/types.ts | 5 +-
87 files changed, 1610 insertions(+), 5193 deletions(-)
create mode 100644 .claude/skills/add-discord/modify/src/channels/index.ts
create mode 100644 .claude/skills/add-discord/modify/src/channels/index.ts.intent.md
delete mode 100644 .claude/skills/add-discord/modify/src/config.ts
delete mode 100644 .claude/skills/add-discord/modify/src/config.ts.intent.md
delete mode 100644 .claude/skills/add-discord/modify/src/index.ts
delete mode 100644 .claude/skills/add-discord/modify/src/index.ts.intent.md
delete mode 100644 .claude/skills/add-discord/modify/src/routing.test.ts
create mode 100644 .claude/skills/add-gmail/modify/src/channels/index.ts
create mode 100644 .claude/skills/add-gmail/modify/src/channels/index.ts.intent.md
delete mode 100644 .claude/skills/add-gmail/modify/src/index.ts
delete mode 100644 .claude/skills/add-gmail/modify/src/index.ts.intent.md
delete mode 100644 .claude/skills/add-gmail/modify/src/routing.test.ts
create mode 100644 .claude/skills/add-slack/modify/src/channels/index.ts
create mode 100644 .claude/skills/add-slack/modify/src/channels/index.ts.intent.md
delete mode 100644 .claude/skills/add-slack/modify/src/config.ts
delete mode 100644 .claude/skills/add-slack/modify/src/config.ts.intent.md
delete mode 100644 .claude/skills/add-slack/modify/src/index.ts
delete mode 100644 .claude/skills/add-slack/modify/src/index.ts.intent.md
delete mode 100644 .claude/skills/add-slack/modify/src/routing.test.ts
delete mode 100644 .claude/skills/add-slack/modify/src/routing.test.ts.intent.md
create mode 100644 .claude/skills/add-telegram/modify/src/channels/index.ts
create mode 100644 .claude/skills/add-telegram/modify/src/channels/index.ts.intent.md
delete mode 100644 .claude/skills/add-telegram/modify/src/config.ts
delete mode 100644 .claude/skills/add-telegram/modify/src/config.ts.intent.md
delete mode 100644 .claude/skills/add-telegram/modify/src/index.ts
delete mode 100644 .claude/skills/add-telegram/modify/src/index.ts.intent.md
delete mode 100644 .claude/skills/add-telegram/modify/src/routing.test.ts
create mode 100644 .claude/skills/add-whatsapp/SKILL.md
rename {setup => .claude/skills/add-whatsapp/add/setup}/whatsapp-auth.ts (98%)
rename {src => .claude/skills/add-whatsapp/add/src}/channels/whatsapp.test.ts (100%)
rename {src => .claude/skills/add-whatsapp/add/src}/channels/whatsapp.ts (98%)
rename {src => .claude/skills/add-whatsapp/add/src}/whatsapp-auth.ts (100%)
create mode 100644 .claude/skills/add-whatsapp/manifest.yaml
create mode 100644 .claude/skills/add-whatsapp/modify/setup/index.ts
create mode 100644 .claude/skills/add-whatsapp/modify/setup/index.ts.intent.md
create mode 100644 .claude/skills/add-whatsapp/modify/src/channels/index.ts
create mode 100644 .claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md
create mode 100644 .claude/skills/add-whatsapp/tests/whatsapp.test.ts
create mode 100644 src/channels/index.ts
create mode 100644 src/channels/registry.test.ts
create mode 100644 src/channels/registry.ts
diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md
index b73e5ad..0522bd1 100644
--- a/.claude/skills/add-discord/SKILL.md
+++ b/.claude/skills/add-discord/SKILL.md
@@ -12,10 +12,6 @@ Read `.nanoclaw/state.yaml`. If `discord` is in `applied_skills`, skip to Phase
Use `AskUserQuestion` to collect configuration:
-AskUserQuestion: Should Discord replace WhatsApp or run alongside it?
-- **Replace WhatsApp** - Discord will be the only channel (sets DISCORD_ONLY=true)
-- **Alongside** - Both Discord and WhatsApp channels active
-
AskUserQuestion: Do you have a Discord bot token, or do you need to create one?
If they have one, collect it now. If not, we'll create one in Phase 3.
@@ -41,18 +37,14 @@ npx tsx scripts/apply-skill.ts .claude/skills/add-discord
```
This deterministically:
-- Adds `src/channels/discord.ts` (DiscordChannel class implementing Channel interface)
+- Adds `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock)
-- Three-way merges Discord support into `src/index.ts` (multi-channel support, findChannel routing)
-- Three-way merges Discord config into `src/config.ts` (DISCORD_BOT_TOKEN, DISCORD_ONLY exports)
-- Three-way merges updated routing tests into `src/routing.test.ts`
+- Appends `import './discord.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `discord.js` npm dependency
-- Updates `.env.example` with `DISCORD_BOT_TOKEN` and `DISCORD_ONLY`
- Records the application in `.nanoclaw/state.yaml`
-If the apply reports merge conflicts, read the intent files:
-- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
-- `modify/src/config.ts.intent.md` — what changed for config.ts
+If the apply reports merge conflicts, read the intent file:
+- `modify/src/channels/index.ts.intent.md` — what changed and invariants
### Validate code changes
@@ -93,16 +85,12 @@ Add to `.env`:
DISCORD_BOT_TOKEN=
```
-If they chose to replace WhatsApp:
-
-```bash
-DISCORD_ONLY=true
-```
+Channels auto-enable when their credentials are present — no extra configuration needed.
Sync to container environment:
```bash
-cp .env data/env/env
+mkdir -p data/env && cp .env data/env/env
```
The container reads environment from `data/env/env`, not `.env` directly.
@@ -134,15 +122,16 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
-For a main channel (responds to all messages, uses the `main` folder):
+For a main channel (responds to all messages):
```typescript
registerGroup("dc:", {
name: " #",
- folder: "main",
+ folder: "discord_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
+ isMain: true,
});
```
@@ -151,7 +140,7 @@ For additional channels (trigger-only):
```typescript
registerGroup("dc:", {
name: " #",
- folder: "",
+ folder: "discord_",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
diff --git a/.claude/skills/add-discord/add/src/channels/discord.test.ts b/.claude/skills/add-discord/add/src/channels/discord.test.ts
index eff0b77..5dbfb50 100644
--- a/.claude/skills/add-discord/add/src/channels/discord.test.ts
+++ b/.claude/skills/add-discord/add/src/channels/discord.test.ts
@@ -2,6 +2,12 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
+// Mock registry (registerChannel runs at import time)
+vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
+
+// Mock env reader (used by the factory, not needed in unit tests)
+vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
+
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
@@ -256,6 +262,8 @@ describe('DiscordChannel', () => {
'dc:1234567890123456',
expect.any(String),
'Test Server #general',
+ 'discord',
+ true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
@@ -286,6 +294,8 @@ describe('DiscordChannel', () => {
'dc:9999999999999999',
expect.any(String),
expect.any(String),
+ 'discord',
+ true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
@@ -365,6 +375,8 @@ describe('DiscordChannel', () => {
'dc:1234567890123456',
expect.any(String),
'Alice',
+ 'discord',
+ false,
);
});
@@ -384,6 +396,8 @@ describe('DiscordChannel', () => {
'dc:1234567890123456',
expect.any(String),
'My Server #bot-chat',
+ 'discord',
+ true,
);
});
});
diff --git a/.claude/skills/add-discord/add/src/channels/discord.ts b/.claude/skills/add-discord/add/src/channels/discord.ts
index 997d489..13f07ba 100644
--- a/.claude/skills/add-discord/add/src/channels/discord.ts
+++ b/.claude/skills/add-discord/add/src/channels/discord.ts
@@ -1,7 +1,9 @@
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
+import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
+import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
@@ -122,7 +124,8 @@ export class DiscordChannel implements Channel {
}
// Store chat metadata for discovery
- this.opts.onChatMetadata(chatJid, timestamp, chatName);
+ const isGroup = message.guild !== null;
+ this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
@@ -234,3 +237,14 @@ export class DiscordChannel implements Channel {
}
}
}
+
+registerChannel('discord', (opts: ChannelOpts) => {
+ const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
+ const token =
+ process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || '';
+ if (!token) {
+ logger.warn('Discord: DISCORD_BOT_TOKEN not set');
+ return null;
+ }
+ return new DiscordChannel(token, opts);
+});
diff --git a/.claude/skills/add-discord/manifest.yaml b/.claude/skills/add-discord/manifest.yaml
index f2cf2c8..c5bec61 100644
--- a/.claude/skills/add-discord/manifest.yaml
+++ b/.claude/skills/add-discord/manifest.yaml
@@ -6,15 +6,12 @@ adds:
- src/channels/discord.ts
- src/channels/discord.test.ts
modifies:
- - src/index.ts
- - src/config.ts
- - src/routing.test.ts
+ - src/channels/index.ts
structured:
npm_dependencies:
discord.js: "^14.18.0"
env_additions:
- DISCORD_BOT_TOKEN
- - DISCORD_ONLY
conflicts: []
depends: []
test: "npx vitest run src/channels/discord.test.ts"
diff --git a/.claude/skills/add-discord/modify/src/channels/index.ts b/.claude/skills/add-discord/modify/src/channels/index.ts
new file mode 100644
index 0000000..3916e5e
--- /dev/null
+++ b/.claude/skills/add-discord/modify/src/channels/index.ts
@@ -0,0 +1,13 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+import './discord.js';
+
+// gmail
+
+// slack
+
+// telegram
+
+// whatsapp
diff --git a/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md b/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md
new file mode 100644
index 0000000..baba3f5
--- /dev/null
+++ b/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md
@@ -0,0 +1,7 @@
+# Intent: Add Discord channel import
+
+Add `import './discord.js';` to the channel barrel file so the Discord
+module self-registers with the channel registry on startup.
+
+This is an append-only change — existing import lines for other channels
+must be preserved.
diff --git a/.claude/skills/add-discord/modify/src/config.ts b/.claude/skills/add-discord/modify/src/config.ts
deleted file mode 100644
index 5f3fa6a..0000000
--- a/.claude/skills/add-discord/modify/src/config.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import os from 'os';
-import path from 'path';
-
-import { readEnvFile } from './env.js';
-
-// Read config values from .env (falls back to process.env).
-// Secrets are NOT read here — they stay on disk and are loaded only
-// where needed (container-runner.ts) to avoid leaking to child processes.
-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 POLL_INTERVAL = 2000;
-export const SCHEDULER_POLL_INTERVAL = 60000;
-
-// Absolute paths needed for container mounts
-const PROJECT_ROOT = process.cwd();
-const HOME_DIR = process.env.HOME || os.homedir();
-
-// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
-export const MOUNT_ALLOWLIST_PATH = path.join(
- HOME_DIR,
- '.config',
- 'nanoclaw',
- 'mount-allowlist.json',
-);
-export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
-export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
-export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
-export const MAIN_GROUP_FOLDER = 'main';
-
-export const CONTAINER_IMAGE =
- process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
-export const CONTAINER_TIMEOUT = parseInt(
- process.env.CONTAINER_TIMEOUT || '1800000',
- 10,
-);
-export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
- process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
- 10,
-); // 10MB default
-export const IPC_POLL_INTERVAL = 1000;
-export const IDLE_TIMEOUT = parseInt(
- process.env.IDLE_TIMEOUT || '1800000',
- 10,
-); // 30min default — how long to keep container alive after last result
-export const MAX_CONCURRENT_CONTAINERS = Math.max(
- 1,
- parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
-);
-
-function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
-export const TRIGGER_PATTERN = new RegExp(
- `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
- 'i',
-);
-
-// Timezone for scheduled tasks (cron expressions, etc.)
-// Uses system timezone by default
-export const TIMEZONE =
- process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
-
-// Discord configuration
-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';
diff --git a/.claude/skills/add-discord/modify/src/config.ts.intent.md b/.claude/skills/add-discord/modify/src/config.ts.intent.md
deleted file mode 100644
index a88fabe..0000000
--- a/.claude/skills/add-discord/modify/src/config.ts.intent.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Intent: src/config.ts modifications
-
-## What changed
-Added two new configuration exports for Discord channel support.
-
-## Key sections
-- **readEnvFile call**: Must include `DISCORD_BOT_TOKEN` and `DISCORD_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
-- **DISCORD_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
-- **DISCORD_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
-
-## Invariants
-- All existing config exports remain unchanged
-- New Discord keys are added to the `readEnvFile` call alongside existing keys
-- New exports are appended at the end of the file
-- No existing behavior is modified — Discord config is additive only
-- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
-
-## Must-keep
-- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
-- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
-- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.claude/skills/add-discord/modify/src/index.ts b/.claude/skills/add-discord/modify/src/index.ts
deleted file mode 100644
index 4b6f30e..0000000
--- a/.claude/skills/add-discord/modify/src/index.ts
+++ /dev/null
@@ -1,509 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-import {
- ASSISTANT_NAME,
- 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,
- runContainerAgent,
- writeGroupsSnapshot,
- writeTasksSnapshot,
-} from './container-runner.js';
-import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
-import {
- getAllChats,
- getAllRegisteredGroups,
- getAllSessions,
- getAllTasks,
- getMessagesSince,
- getNewMessages,
- getRouterState,
- initDatabase,
- setRegisteredGroup,
- setRouterState,
- setSession,
- storeChatMetadata,
- storeMessage,
-} from './db.js';
-import { GroupQueue } from './group-queue.js';
-import { resolveGroupFolderPath } from './group-folder.js';
-import { startIpcWatcher } from './ipc.js';
-import { findChannel, formatMessages, formatOutbound } from './router.js';
-import { startSchedulerLoop } from './task-scheduler.js';
-import { Channel, NewMessage, RegisteredGroup } from './types.js';
-import { logger } from './logger.js';
-
-// Re-export for backwards compatibility during refactor
-export { escapeXml, formatMessages } from './router.js';
-
-let lastTimestamp = '';
-let sessions: Record = {};
-let registeredGroups: Record = {};
-let lastAgentTimestamp: Record = {};
-let messageLoopRunning = false;
-
-let whatsapp: WhatsAppChannel;
-const channels: Channel[] = [];
-const queue = new GroupQueue();
-
-function loadState(): void {
- lastTimestamp = getRouterState('last_timestamp') || '';
- const agentTs = getRouterState('last_agent_timestamp');
- try {
- lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
- } catch {
- logger.warn('Corrupted last_agent_timestamp in DB, resetting');
- lastAgentTimestamp = {};
- }
- sessions = getAllSessions();
- registeredGroups = getAllRegisteredGroups();
- logger.info(
- { groupCount: Object.keys(registeredGroups).length },
- 'State loaded',
- );
-}
-
-function saveState(): void {
- setRouterState('last_timestamp', lastTimestamp);
- setRouterState(
- 'last_agent_timestamp',
- JSON.stringify(lastAgentTimestamp),
- );
-}
-
-function registerGroup(jid: string, group: RegisteredGroup): void {
- let groupDir: string;
- try {
- groupDir = resolveGroupFolderPath(group.folder);
- } catch (err) {
- logger.warn(
- { jid, folder: group.folder, err },
- 'Rejecting group registration with invalid folder',
- );
- return;
- }
-
- registeredGroups[jid] = group;
- setRegisteredGroup(jid, group);
-
- // Create group folder
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
-
- logger.info(
- { jid, name: group.name, folder: group.folder },
- 'Group registered',
- );
-}
-
-/**
- * Get available groups list for the agent.
- * Returns groups ordered by most recent activity.
- */
-export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
- const chats = getAllChats();
- const registeredJids = new Set(Object.keys(registeredGroups));
-
- return chats
- .filter((c) => c.jid !== '__group_sync__' && c.is_group)
- .map((c) => ({
- jid: c.jid,
- name: c.name,
- lastActivity: c.last_message_time,
- isRegistered: registeredJids.has(c.jid),
- }));
-}
-
-/** @internal - exported for testing */
-export function _setRegisteredGroups(groups: Record): void {
- registeredGroups = groups;
-}
-
-/**
- * Process all pending messages for a group.
- * Called by the GroupQueue when it's this group's turn.
- */
-async function processGroupMessages(chatJid: string): Promise {
- const group = registeredGroups[chatJid];
- if (!group) return true;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- return true;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
-
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
-
- if (missedMessages.length === 0) return true;
-
- // For non-main groups, check if trigger is required and present
- if (!isMainGroup && group.requiresTrigger !== false) {
- const hasTrigger = missedMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) return true;
- }
-
- const prompt = formatMessages(missedMessages);
-
- // Advance cursor so the piping path in startMessageLoop won't re-fetch
- // these messages. Save the old cursor so we can roll back on error.
- const previousCursor = lastAgentTimestamp[chatJid] || '';
- lastAgentTimestamp[chatJid] =
- missedMessages[missedMessages.length - 1].timestamp;
- saveState();
-
- logger.info(
- { group: group.name, messageCount: missedMessages.length },
- 'Processing messages',
- );
-
- // Track idle timer for closing stdin when agent is idle
- let idleTimer: ReturnType | null = null;
-
- const resetIdleTimer = () => {
- if (idleTimer) clearTimeout(idleTimer);
- idleTimer = setTimeout(() => {
- logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
- queue.closeStdin(chatJid);
- }, IDLE_TIMEOUT);
- };
-
- await channel.setTyping?.(chatJid, true);
- let hadError = false;
- let outputSentToUser = false;
-
- const output = await runAgent(group, prompt, chatJid, async (result) => {
- // Streaming output callback — called for each agent result
- if (result.result) {
- const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
- // Strip ... blocks — agent uses these for internal reasoning
- const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
- logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
- if (text) {
- await channel.sendMessage(chatJid, text);
- outputSentToUser = true;
- }
- // Only reset idle timer on actual results, not session-update markers (result: null)
- resetIdleTimer();
- }
-
- if (result.status === 'success') {
- queue.notifyIdle(chatJid);
- }
-
- if (result.status === 'error') {
- hadError = true;
- }
- });
-
- await channel.setTyping?.(chatJid, false);
- if (idleTimer) clearTimeout(idleTimer);
-
- if (output === 'error' || hadError) {
- // If we already sent output to the user, don't roll back the cursor —
- // the user got their response and re-processing would send duplicates.
- if (outputSentToUser) {
- logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
- return true;
- }
- // Roll back cursor so retries can re-process these messages
- lastAgentTimestamp[chatJid] = previousCursor;
- saveState();
- logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
- return false;
- }
-
- return true;
-}
-
-async function runAgent(
- group: RegisteredGroup,
- prompt: string,
- chatJid: string,
- onOutput?: (output: ContainerOutput) => Promise,
-): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
- const sessionId = sessions[group.folder];
-
- // Update tasks snapshot for container to read (filtered by group)
- const tasks = getAllTasks();
- writeTasksSnapshot(
- group.folder,
- isMain,
- tasks.map((t) => ({
- id: t.id,
- groupFolder: t.group_folder,
- prompt: t.prompt,
- schedule_type: t.schedule_type,
- schedule_value: t.schedule_value,
- status: t.status,
- next_run: t.next_run,
- })),
- );
-
- // Update available groups snapshot (main group only can see all groups)
- const availableGroups = getAvailableGroups();
- writeGroupsSnapshot(
- group.folder,
- isMain,
- availableGroups,
- new Set(Object.keys(registeredGroups)),
- );
-
- // Wrap onOutput to track session ID from streamed results
- const wrappedOnOutput = onOutput
- ? async (output: ContainerOutput) => {
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
- await onOutput(output);
- }
- : undefined;
-
- try {
- const output = await runContainerAgent(
- group,
- {
- prompt,
- sessionId,
- groupFolder: group.folder,
- chatJid,
- isMain,
- assistantName: ASSISTANT_NAME,
- },
- (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
- wrappedOnOutput,
- );
-
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
-
- if (output.status === 'error') {
- logger.error(
- { group: group.name, error: output.error },
- 'Container agent error',
- );
- return 'error';
- }
-
- return 'success';
- } catch (err) {
- logger.error({ group: group.name, err }, 'Agent error');
- return 'error';
- }
-}
-
-async function startMessageLoop(): Promise {
- if (messageLoopRunning) {
- logger.debug('Message loop already running, skipping duplicate start');
- return;
- }
- messageLoopRunning = true;
-
- logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
-
- while (true) {
- try {
- const jids = Object.keys(registeredGroups);
- const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
-
- if (messages.length > 0) {
- logger.info({ count: messages.length }, 'New messages');
-
- // Advance the "seen" cursor for all messages immediately
- lastTimestamp = newTimestamp;
- saveState();
-
- // Deduplicate by group
- const messagesByGroup = new Map();
- for (const msg of messages) {
- const existing = messagesByGroup.get(msg.chat_jid);
- if (existing) {
- existing.push(msg);
- } else {
- messagesByGroup.set(msg.chat_jid, [msg]);
- }
- }
-
- for (const [chatJid, groupMessages] of messagesByGroup) {
- const group = registeredGroups[chatJid];
- if (!group) continue;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- continue;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
- const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
-
- // For non-main groups, only act on trigger messages.
- // Non-trigger messages accumulate in DB and get pulled as
- // context when a trigger eventually arrives.
- if (needsTrigger) {
- const hasTrigger = groupMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) continue;
- }
-
- // Pull all messages since lastAgentTimestamp so non-trigger
- // context that accumulated between triggers is included.
- const allPending = getMessagesSince(
- chatJid,
- lastAgentTimestamp[chatJid] || '',
- ASSISTANT_NAME,
- );
- const messagesToSend =
- allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
-
- if (queue.sendMessage(chatJid, formatted)) {
- logger.debug(
- { chatJid, count: messagesToSend.length },
- 'Piped messages to active container',
- );
- lastAgentTimestamp[chatJid] =
- messagesToSend[messagesToSend.length - 1].timestamp;
- saveState();
- // Show typing indicator while the container processes the piped message
- channel.setTyping?.(chatJid, true)?.catch((err) =>
- logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
- );
- } else {
- // No active container — enqueue for a new one
- queue.enqueueMessageCheck(chatJid);
- }
- }
- }
- } catch (err) {
- logger.error({ err }, 'Error in message loop');
- }
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
- }
-}
-
-/**
- * Startup recovery: check for unprocessed messages in registered groups.
- * Handles crash between advancing lastTimestamp and processing messages.
- */
-function recoverPendingMessages(): void {
- for (const [chatJid, group] of Object.entries(registeredGroups)) {
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
- if (pending.length > 0) {
- logger.info(
- { group: group.name, pendingCount: pending.length },
- 'Recovery: found unprocessed messages',
- );
- queue.enqueueMessageCheck(chatJid);
- }
- }
-}
-
-function ensureContainerSystemRunning(): void {
- ensureContainerRuntimeRunning();
- cleanupOrphans();
-}
-
-async function main(): Promise {
- ensureContainerSystemRunning();
- initDatabase();
- logger.info('Database initialized');
- loadState();
-
- // Graceful shutdown handlers
- const shutdown = async (signal: string) => {
- logger.info({ signal }, 'Shutdown signal received');
- await queue.shutdown(10000);
- for (const ch of channels) await ch.disconnect();
- process.exit(0);
- };
- process.on('SIGTERM', () => shutdown('SIGTERM'));
- process.on('SIGINT', () => shutdown('SIGINT'));
-
- // Channel callbacks (shared by all channels)
- const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
- onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
- storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
- registeredGroups: () => registeredGroups,
- };
-
- // Create and connect channels
- 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({
- registeredGroups: () => registeredGroups,
- getSessions: () => sessions,
- queue,
- 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;
- }
- const text = formatOutbound(rawText);
- if (text) await channel.sendMessage(jid, text);
- },
- });
- startIpcWatcher({
- sendMessage: (jid, text) => {
- const channel = findChannel(channels, jid);
- if (!channel) throw new Error(`No channel for JID: ${jid}`);
- return channel.sendMessage(jid, text);
- },
- registeredGroups: () => registeredGroups,
- registerGroup,
- syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
- getAvailableGroups,
- writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
- });
- queue.setProcessMessagesFn(processGroupMessages);
- recoverPendingMessages();
- startMessageLoop().catch((err) => {
- logger.fatal({ err }, 'Message loop crashed unexpectedly');
- process.exit(1);
- });
-}
-
-// Guard: only run when executed directly, not when imported by tests
-const isDirectRun =
- process.argv[1] &&
- new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
-
-if (isDirectRun) {
- main().catch((err) => {
- logger.error({ err }, 'Failed to start NanoClaw');
- process.exit(1);
- });
-}
diff --git a/.claude/skills/add-discord/modify/src/index.ts.intent.md b/.claude/skills/add-discord/modify/src/index.ts.intent.md
deleted file mode 100644
index a02ef52..0000000
--- a/.claude/skills/add-discord/modify/src/index.ts.intent.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Intent: src/index.ts modifications
-
-## What changed
-Added Discord as a channel option alongside WhatsApp, introducing multi-channel infrastructure.
-
-## Key sections
-
-### Imports (top of file)
-- Added: `DiscordChannel` from `./channels/discord.js`
-- Added: `DISCORD_BOT_TOKEN`, `DISCORD_ONLY` from `./config.js`
-- Added: `findChannel` from `./router.js`
-- Added: `Channel` from `./types.js`
-
-### Multi-channel infrastructure
-- Added: `const channels: Channel[] = []` array to hold all active channels
-- Changed: `processGroupMessages` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
-- Changed: `startMessageLoop` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
-- Changed: `channel.setTyping?.()` instead of `whatsapp.setTyping()`
-- Changed: `channel.sendMessage()` instead of `whatsapp.sendMessage()`
-
-### getAvailableGroups()
-- Unchanged: uses `c.is_group` filter from base (Discord channels pass `isGroup=true` via `onChatMetadata`)
-
-### main()
-- Added: `channelOpts` shared callback object for all channels
-- Changed: WhatsApp conditional to `if (!DISCORD_ONLY)`
-- Added: conditional Discord creation (`if (DISCORD_BOT_TOKEN)`)
-- Changed: shutdown iterates `channels` array instead of just `whatsapp`
-- Changed: subsystems use `findChannel(channels, jid)` for message routing
-
-## Invariants
-- All existing message processing logic (triggers, cursors, idle timers) is preserved
-- The `runAgent` function is completely unchanged
-- State management (loadState/saveState) is unchanged
-- Recovery logic is unchanged
-- Container runtime check is unchanged (ensureContainerSystemRunning)
-
-## Must-keep
-- The `escapeXml` and `formatMessages` re-exports
-- The `_setRegisteredGroups` test helper
-- The `isDirectRun` guard at bottom
-- All error handling and cursor rollback logic in processGroupMessages
-- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)
diff --git a/.claude/skills/add-discord/modify/src/routing.test.ts b/.claude/skills/add-discord/modify/src/routing.test.ts
deleted file mode 100644
index 6144af0..0000000
--- a/.claude/skills/add-discord/modify/src/routing.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-
-import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
-import { getAvailableGroups, _setRegisteredGroups } from './index.js';
-
-beforeEach(() => {
- _initTestDatabase();
- _setRegisteredGroups({});
-});
-
-// --- JID ownership patterns ---
-
-describe('JID ownership patterns', () => {
- // These test the patterns that will become ownsJid() on the Channel interface
-
- it('WhatsApp group JID: ends with @g.us', () => {
- const jid = '12345678@g.us';
- 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);
- });
-});
-
-// --- getAvailableGroups ---
-
-describe('getAvailableGroups', () => {
- it('returns only groups, excludes DMs', () => {
- storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
- storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
- storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.jid)).toContain('group1@g.us');
- expect(groups.map((g) => g.jid)).toContain('group2@g.us');
- expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
- });
-
- 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 __group_sync__ sentinel', () => {
- storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
- storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('marks registered groups correctly', () => {
- storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
- storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
-
- _setRegisteredGroups({
- 'reg@g.us': {
- name: 'Registered',
- folder: 'registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const reg = groups.find((g) => g.jid === 'reg@g.us');
- const unreg = groups.find((g) => g.jid === 'unreg@g.us');
-
- expect(reg?.isRegistered).toBe(true);
- expect(unreg?.isRegistered).toBe(false);
- });
-
- it('returns groups ordered by most recent activity', () => {
- storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
- storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
- storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups[0].jid).toBe('new@g.us');
- expect(groups[1].jid).toBe('mid@g.us');
- expect(groups[2].jid).toBe('old@g.us');
- });
-
- 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');
- // Explicitly non-group with unusual JID
- storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
- // A real group for contrast
- storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('returns empty array when no chats exist', () => {
- 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');
- });
-});
diff --git a/.claude/skills/add-discord/tests/discord.test.ts b/.claude/skills/add-discord/tests/discord.test.ts
index a644aa7..b51411c 100644
--- a/.claude/skills/add-discord/tests/discord.test.ts
+++ b/.claude/skills/add-discord/tests/discord.test.ts
@@ -16,15 +16,28 @@ describe('discord skill package', () => {
});
it('has all files declared in adds', () => {
- const addFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.ts');
- expect(fs.existsSync(addFile)).toBe(true);
+ const channelFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'discord.ts',
+ );
+ expect(fs.existsSync(channelFile)).toBe(true);
- const content = fs.readFileSync(addFile, 'utf-8');
+ const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class DiscordChannel');
expect(content).toContain('implements Channel');
+ expect(content).toContain("registerChannel('discord'");
// Test file for the channel
- const testFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.test.ts');
+ const testFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'discord.test.ts',
+ );
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
@@ -32,102 +45,25 @@ describe('discord skill package', () => {
});
it('has all files declared in modifies', () => {
- const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
- const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
- const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
-
+ // Channel barrel file
+ const indexFile = path.join(
+ skillDir,
+ 'modify',
+ 'src',
+ 'channels',
+ 'index.ts',
+ );
expect(fs.existsSync(indexFile)).toBe(true);
- expect(fs.existsSync(configFile)).toBe(true);
- expect(fs.existsSync(routingTestFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
- expect(indexContent).toContain('DiscordChannel');
- expect(indexContent).toContain('DISCORD_BOT_TOKEN');
- expect(indexContent).toContain('DISCORD_ONLY');
- expect(indexContent).toContain('findChannel');
- expect(indexContent).toContain('channels: Channel[]');
-
- const configContent = fs.readFileSync(configFile, 'utf-8');
- expect(configContent).toContain('DISCORD_BOT_TOKEN');
- expect(configContent).toContain('DISCORD_ONLY');
+ expect(indexContent).toContain("import './discord.js'");
});
it('has intent files for modified files', () => {
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
- });
-
- it('modified index.ts preserves core structure', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Core functions still present
- expect(content).toContain('function loadState()');
- expect(content).toContain('function saveState()');
- expect(content).toContain('function registerGroup(');
- expect(content).toContain('function getAvailableGroups()');
- expect(content).toContain('function processGroupMessages(');
- expect(content).toContain('function runAgent(');
- expect(content).toContain('function startMessageLoop()');
- expect(content).toContain('function recoverPendingMessages()');
- expect(content).toContain('function ensureContainerSystemRunning()');
- expect(content).toContain('async function main()');
-
- // Test helper preserved
- expect(content).toContain('_setRegisteredGroups');
-
- // Direct-run guard preserved
- expect(content).toContain('isDirectRun');
- });
-
- it('modified index.ts includes Discord channel creation', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Multi-channel architecture
- expect(content).toContain('const channels: Channel[] = []');
- expect(content).toContain('channels.push(whatsapp)');
- expect(content).toContain('channels.push(discord)');
-
- // Conditional channel creation
- expect(content).toContain('if (!DISCORD_ONLY)');
- expect(content).toContain('if (DISCORD_BOT_TOKEN)');
-
- // Shutdown disconnects all channels
- expect(content).toContain('for (const ch of channels) await ch.disconnect()');
- });
-
- it('modified config.ts preserves all existing exports', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'config.ts'),
- 'utf-8',
- );
-
- // All original exports preserved
- expect(content).toContain('export const ASSISTANT_NAME');
- expect(content).toContain('export const POLL_INTERVAL');
- expect(content).toContain('export const TRIGGER_PATTERN');
- expect(content).toContain('export const CONTAINER_IMAGE');
- expect(content).toContain('export const DATA_DIR');
- expect(content).toContain('export const TIMEZONE');
-
- // Discord exports added
- expect(content).toContain('export const DISCORD_BOT_TOKEN');
- expect(content).toContain('export const DISCORD_ONLY');
- });
-
- it('modified routing.test.ts includes Discord JID tests', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'routing.test.ts'),
- 'utf-8',
- );
-
- expect(content).toContain("Discord JID: starts with dc:");
- expect(content).toContain("dc:1234567890123456");
- expect(content).toContain("dc:");
+ expect(
+ fs.existsSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
+ ),
+ ).toBe(true);
});
});
diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md
index f4267cc..b8d2c25 100644
--- a/.claude/skills/add-gmail/SKILL.md
+++ b/.claude/skills/add-gmail/SKILL.md
@@ -66,18 +66,17 @@ npx tsx scripts/apply-skill.ts .claude/skills/add-gmail
This deterministically:
-- Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface)
+- Adds `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
- Adds `src/channels/gmail.test.ts` (unit tests)
-- Three-way merges Gmail channel wiring into `src/index.ts` (GmailChannel creation)
+- Appends `import './gmail.js'` to the channel barrel file `src/channels/index.ts`
- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp)
- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp)
-- Three-way merges Gmail JID tests into `src/routing.test.ts`
- Installs the `googleapis` npm dependency
- Records the application in `.nanoclaw/state.yaml`
If the apply reports merge conflicts, read the intent files:
-- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
+- `modify/src/channels/index.ts.intent.md` — what changed for the barrel file
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner
@@ -234,11 +233,10 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp
### Channel mode
1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts`
-2. Remove `GmailChannel` import and creation from `src/index.ts`
+2. Remove `import './gmail.js'` from `src/channels/index.ts`
3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
-5. Remove Gmail JID tests from `src/routing.test.ts`
-6. Uninstall: `npm uninstall googleapis`
-7. Remove `gmail` from `.nanoclaw/state.yaml`
-8. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
-9. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+5. Uninstall: `npm uninstall googleapis`
+6. Remove `gmail` from `.nanoclaw/state.yaml`
+7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
+8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.test.ts b/.claude/skills/add-gmail/add/src/channels/gmail.test.ts
index 52602dd..afdb15b 100644
--- a/.claude/skills/add-gmail/add/src/channels/gmail.test.ts
+++ b/.claude/skills/add-gmail/add/src/channels/gmail.test.ts
@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+// Mock registry (registerChannel runs at import time)
+vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
+
import { GmailChannel, GmailChannelOpts } from './gmail.js';
function makeOpts(overrides?: Partial): GmailChannelOpts {
diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.ts b/.claude/skills/add-gmail/add/src/channels/gmail.ts
index b9ade60..131f55a 100644
--- a/.claude/skills/add-gmail/add/src/channels/gmail.ts
+++ b/.claude/skills/add-gmail/add/src/channels/gmail.ts
@@ -5,8 +5,9 @@ import path from 'path';
import { google, gmail_v1 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
-import { MAIN_GROUP_FOLDER } from '../config.js';
+// isMain flag is used instead of MAIN_GROUP_FOLDER constant
import { logger } from '../logger.js';
+import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
@@ -268,7 +269,7 @@ export class GmailChannel implements Channel {
// Find the main group to deliver the email notification
const groups = this.opts.registeredGroups();
const mainEntry = Object.entries(groups).find(
- ([, g]) => g.folder === MAIN_GROUP_FOLDER,
+ ([, g]) => g.isMain === true,
);
if (!mainEntry) {
@@ -337,3 +338,15 @@ export class GmailChannel implements Channel {
return '';
}
}
+
+registerChannel('gmail', (opts: ChannelOpts) => {
+ const credDir = path.join(os.homedir(), '.gmail-mcp');
+ if (
+ !fs.existsSync(path.join(credDir, 'gcp-oauth.keys.json')) ||
+ !fs.existsSync(path.join(credDir, 'credentials.json'))
+ ) {
+ logger.warn('Gmail: credentials not found in ~/.gmail-mcp/');
+ return null;
+ }
+ return new GmailChannel(opts);
+});
diff --git a/.claude/skills/add-gmail/manifest.yaml b/.claude/skills/add-gmail/manifest.yaml
index ea7c66a..1123c56 100644
--- a/.claude/skills/add-gmail/manifest.yaml
+++ b/.claude/skills/add-gmail/manifest.yaml
@@ -6,10 +6,9 @@ adds:
- src/channels/gmail.ts
- src/channels/gmail.test.ts
modifies:
- - src/index.ts
+ - src/channels/index.ts
- src/container-runner.ts
- container/agent-runner/src/index.ts
- - src/routing.test.ts
structured:
npm_dependencies:
googleapis: "^144.0.0"
diff --git a/.claude/skills/add-gmail/modify/src/channels/index.ts b/.claude/skills/add-gmail/modify/src/channels/index.ts
new file mode 100644
index 0000000..53df423
--- /dev/null
+++ b/.claude/skills/add-gmail/modify/src/channels/index.ts
@@ -0,0 +1,13 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+import './gmail.js';
+
+// slack
+
+// telegram
+
+// whatsapp
diff --git a/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md b/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md
new file mode 100644
index 0000000..3b0518d
--- /dev/null
+++ b/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md
@@ -0,0 +1,7 @@
+# Intent: Add Gmail channel import
+
+Add `import './gmail.js';` to the channel barrel file so the Gmail
+module self-registers with the channel registry on startup.
+
+This is an append-only change — existing import lines for other channels
+must be preserved.
diff --git a/.claude/skills/add-gmail/modify/src/index.ts b/.claude/skills/add-gmail/modify/src/index.ts
deleted file mode 100644
index be26a17..0000000
--- a/.claude/skills/add-gmail/modify/src/index.ts
+++ /dev/null
@@ -1,507 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-import {
- ASSISTANT_NAME,
- IDLE_TIMEOUT,
- MAIN_GROUP_FOLDER,
- POLL_INTERVAL,
- TRIGGER_PATTERN,
-} from './config.js';
-import { GmailChannel } from './channels/gmail.js';
-import { WhatsAppChannel } from './channels/whatsapp.js';
-import {
- ContainerOutput,
- runContainerAgent,
- writeGroupsSnapshot,
- writeTasksSnapshot,
-} from './container-runner.js';
-import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
-import {
- getAllChats,
- getAllRegisteredGroups,
- getAllSessions,
- getAllTasks,
- getMessagesSince,
- getNewMessages,
- getRouterState,
- initDatabase,
- setRegisteredGroup,
- setRouterState,
- setSession,
- storeChatMetadata,
- storeMessage,
-} from './db.js';
-import { GroupQueue } from './group-queue.js';
-import { resolveGroupFolderPath } from './group-folder.js';
-import { startIpcWatcher } from './ipc.js';
-import { findChannel, formatMessages, formatOutbound } from './router.js';
-import { startSchedulerLoop } from './task-scheduler.js';
-import { Channel, NewMessage, RegisteredGroup } from './types.js';
-import { logger } from './logger.js';
-
-// Re-export for backwards compatibility during refactor
-export { escapeXml, formatMessages } from './router.js';
-
-let lastTimestamp = '';
-let sessions: Record = {};
-let registeredGroups: Record = {};
-let lastAgentTimestamp: Record = {};
-let messageLoopRunning = false;
-
-let whatsapp: WhatsAppChannel;
-const channels: Channel[] = [];
-const queue = new GroupQueue();
-
-function loadState(): void {
- lastTimestamp = getRouterState('last_timestamp') || '';
- const agentTs = getRouterState('last_agent_timestamp');
- try {
- lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
- } catch {
- logger.warn('Corrupted last_agent_timestamp in DB, resetting');
- lastAgentTimestamp = {};
- }
- sessions = getAllSessions();
- registeredGroups = getAllRegisteredGroups();
- logger.info(
- { groupCount: Object.keys(registeredGroups).length },
- 'State loaded',
- );
-}
-
-function saveState(): void {
- setRouterState('last_timestamp', lastTimestamp);
- setRouterState(
- 'last_agent_timestamp',
- JSON.stringify(lastAgentTimestamp),
- );
-}
-
-function registerGroup(jid: string, group: RegisteredGroup): void {
- let groupDir: string;
- try {
- groupDir = resolveGroupFolderPath(group.folder);
- } catch (err) {
- logger.warn(
- { jid, folder: group.folder, err },
- 'Rejecting group registration with invalid folder',
- );
- return;
- }
-
- registeredGroups[jid] = group;
- setRegisteredGroup(jid, group);
-
- // Create group folder
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
-
- logger.info(
- { jid, name: group.name, folder: group.folder },
- 'Group registered',
- );
-}
-
-/**
- * Get available groups list for the agent.
- * Returns groups ordered by most recent activity.
- */
-export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
- const chats = getAllChats();
- const registeredJids = new Set(Object.keys(registeredGroups));
-
- return chats
- .filter((c) => c.jid !== '__group_sync__' && c.is_group)
- .map((c) => ({
- jid: c.jid,
- name: c.name,
- lastActivity: c.last_message_time,
- isRegistered: registeredJids.has(c.jid),
- }));
-}
-
-/** @internal - exported for testing */
-export function _setRegisteredGroups(groups: Record): void {
- registeredGroups = groups;
-}
-
-/**
- * Process all pending messages for a group.
- * Called by the GroupQueue when it's this group's turn.
- */
-async function processGroupMessages(chatJid: string): Promise {
- const group = registeredGroups[chatJid];
- if (!group) return true;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- return true;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
-
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
-
- if (missedMessages.length === 0) return true;
-
- // For non-main groups, check if trigger is required and present
- if (!isMainGroup && group.requiresTrigger !== false) {
- const hasTrigger = missedMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) return true;
- }
-
- const prompt = formatMessages(missedMessages);
-
- // Advance cursor so the piping path in startMessageLoop won't re-fetch
- // these messages. Save the old cursor so we can roll back on error.
- const previousCursor = lastAgentTimestamp[chatJid] || '';
- lastAgentTimestamp[chatJid] =
- missedMessages[missedMessages.length - 1].timestamp;
- saveState();
-
- logger.info(
- { group: group.name, messageCount: missedMessages.length },
- 'Processing messages',
- );
-
- // Track idle timer for closing stdin when agent is idle
- let idleTimer: ReturnType | null = null;
-
- const resetIdleTimer = () => {
- if (idleTimer) clearTimeout(idleTimer);
- idleTimer = setTimeout(() => {
- logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
- queue.closeStdin(chatJid);
- }, IDLE_TIMEOUT);
- };
-
- await channel.setTyping?.(chatJid, true);
- let hadError = false;
- let outputSentToUser = false;
-
- const output = await runAgent(group, prompt, chatJid, async (result) => {
- // Streaming output callback — called for each agent result
- if (result.result) {
- const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
- // Strip ... blocks — agent uses these for internal reasoning
- const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
- logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
- if (text) {
- await channel.sendMessage(chatJid, text);
- outputSentToUser = true;
- }
- // Only reset idle timer on actual results, not session-update markers (result: null)
- resetIdleTimer();
- }
-
- if (result.status === 'success') {
- queue.notifyIdle(chatJid);
- }
-
- if (result.status === 'error') {
- hadError = true;
- }
- });
-
- await channel.setTyping?.(chatJid, false);
- if (idleTimer) clearTimeout(idleTimer);
-
- if (output === 'error' || hadError) {
- // If we already sent output to the user, don't roll back the cursor —
- // the user got their response and re-processing would send duplicates.
- if (outputSentToUser) {
- logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
- return true;
- }
- // Roll back cursor so retries can re-process these messages
- lastAgentTimestamp[chatJid] = previousCursor;
- saveState();
- logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
- return false;
- }
-
- return true;
-}
-
-async function runAgent(
- group: RegisteredGroup,
- prompt: string,
- chatJid: string,
- onOutput?: (output: ContainerOutput) => Promise,
-): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
- const sessionId = sessions[group.folder];
-
- // Update tasks snapshot for container to read (filtered by group)
- const tasks = getAllTasks();
- writeTasksSnapshot(
- group.folder,
- isMain,
- tasks.map((t) => ({
- id: t.id,
- groupFolder: t.group_folder,
- prompt: t.prompt,
- schedule_type: t.schedule_type,
- schedule_value: t.schedule_value,
- status: t.status,
- next_run: t.next_run,
- })),
- );
-
- // Update available groups snapshot (main group only can see all groups)
- const availableGroups = getAvailableGroups();
- writeGroupsSnapshot(
- group.folder,
- isMain,
- availableGroups,
- new Set(Object.keys(registeredGroups)),
- );
-
- // Wrap onOutput to track session ID from streamed results
- const wrappedOnOutput = onOutput
- ? async (output: ContainerOutput) => {
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
- await onOutput(output);
- }
- : undefined;
-
- try {
- const output = await runContainerAgent(
- group,
- {
- prompt,
- sessionId,
- groupFolder: group.folder,
- chatJid,
- isMain,
- assistantName: ASSISTANT_NAME,
- },
- (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
- wrappedOnOutput,
- );
-
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
-
- if (output.status === 'error') {
- logger.error(
- { group: group.name, error: output.error },
- 'Container agent error',
- );
- return 'error';
- }
-
- return 'success';
- } catch (err) {
- logger.error({ group: group.name, err }, 'Agent error');
- return 'error';
- }
-}
-
-async function startMessageLoop(): Promise {
- if (messageLoopRunning) {
- logger.debug('Message loop already running, skipping duplicate start');
- return;
- }
- messageLoopRunning = true;
-
- logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
-
- while (true) {
- try {
- const jids = Object.keys(registeredGroups);
- const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
-
- if (messages.length > 0) {
- logger.info({ count: messages.length }, 'New messages');
-
- // Advance the "seen" cursor for all messages immediately
- lastTimestamp = newTimestamp;
- saveState();
-
- // Deduplicate by group
- const messagesByGroup = new Map();
- for (const msg of messages) {
- const existing = messagesByGroup.get(msg.chat_jid);
- if (existing) {
- existing.push(msg);
- } else {
- messagesByGroup.set(msg.chat_jid, [msg]);
- }
- }
-
- for (const [chatJid, groupMessages] of messagesByGroup) {
- const group = registeredGroups[chatJid];
- if (!group) continue;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- continue;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
- const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
-
- // For non-main groups, only act on trigger messages.
- // Non-trigger messages accumulate in DB and get pulled as
- // context when a trigger eventually arrives.
- if (needsTrigger) {
- const hasTrigger = groupMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) continue;
- }
-
- // Pull all messages since lastAgentTimestamp so non-trigger
- // context that accumulated between triggers is included.
- const allPending = getMessagesSince(
- chatJid,
- lastAgentTimestamp[chatJid] || '',
- ASSISTANT_NAME,
- );
- const messagesToSend =
- allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
-
- if (queue.sendMessage(chatJid, formatted)) {
- logger.debug(
- { chatJid, count: messagesToSend.length },
- 'Piped messages to active container',
- );
- lastAgentTimestamp[chatJid] =
- messagesToSend[messagesToSend.length - 1].timestamp;
- saveState();
- // Show typing indicator while the container processes the piped message
- channel.setTyping?.(chatJid, true)?.catch((err) =>
- logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
- );
- } else {
- // No active container — enqueue for a new one
- queue.enqueueMessageCheck(chatJid);
- }
- }
- }
- } catch (err) {
- logger.error({ err }, 'Error in message loop');
- }
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
- }
-}
-
-/**
- * Startup recovery: check for unprocessed messages in registered groups.
- * Handles crash between advancing lastTimestamp and processing messages.
- */
-function recoverPendingMessages(): void {
- for (const [chatJid, group] of Object.entries(registeredGroups)) {
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
- if (pending.length > 0) {
- logger.info(
- { group: group.name, pendingCount: pending.length },
- 'Recovery: found unprocessed messages',
- );
- queue.enqueueMessageCheck(chatJid);
- }
- }
-}
-
-function ensureContainerSystemRunning(): void {
- ensureContainerRuntimeRunning();
- cleanupOrphans();
-}
-
-async function main(): Promise {
- ensureContainerSystemRunning();
- initDatabase();
- logger.info('Database initialized');
- loadState();
-
- // Graceful shutdown handlers
- const shutdown = async (signal: string) => {
- logger.info({ signal }, 'Shutdown signal received');
- await queue.shutdown(10000);
- for (const ch of channels) await ch.disconnect();
- process.exit(0);
- };
- process.on('SIGTERM', () => shutdown('SIGTERM'));
- process.on('SIGINT', () => shutdown('SIGINT'));
-
- // Channel callbacks (shared by all channels)
- const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
- onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
- storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
- registeredGroups: () => registeredGroups,
- };
-
- // Create and connect channels
- whatsapp = new WhatsAppChannel(channelOpts);
- channels.push(whatsapp);
- await whatsapp.connect();
-
- const gmail = new GmailChannel(channelOpts);
- channels.push(gmail);
- try {
- await gmail.connect();
- } catch (err) {
- logger.warn({ err }, 'Gmail channel failed to connect, continuing without it');
- }
-
- // Start subsystems (independently of connection handler)
- startSchedulerLoop({
- registeredGroups: () => registeredGroups,
- getSessions: () => sessions,
- queue,
- 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;
- }
- const text = formatOutbound(rawText);
- if (text) await channel.sendMessage(jid, text);
- },
- });
- startIpcWatcher({
- sendMessage: (jid, text) => {
- const channel = findChannel(channels, jid);
- if (!channel) throw new Error(`No channel for JID: ${jid}`);
- return channel.sendMessage(jid, text);
- },
- registeredGroups: () => registeredGroups,
- registerGroup,
- syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
- getAvailableGroups,
- writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
- });
- queue.setProcessMessagesFn(processGroupMessages);
- recoverPendingMessages();
- startMessageLoop().catch((err) => {
- logger.fatal({ err }, 'Message loop crashed unexpectedly');
- process.exit(1);
- });
-}
-
-// Guard: only run when executed directly, not when imported by tests
-const isDirectRun =
- process.argv[1] &&
- new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
-
-if (isDirectRun) {
- main().catch((err) => {
- logger.error({ err }, 'Failed to start NanoClaw');
- process.exit(1);
- });
-}
diff --git a/.claude/skills/add-gmail/modify/src/index.ts.intent.md b/.claude/skills/add-gmail/modify/src/index.ts.intent.md
deleted file mode 100644
index cd700f5..0000000
--- a/.claude/skills/add-gmail/modify/src/index.ts.intent.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Intent: src/index.ts modifications
-
-## What changed
-
-Added Gmail as a channel.
-
-## Key sections
-
-### Imports (top of file)
-
-- Added: `GmailChannel` from `./channels/gmail.js`
-
-### main()
-
-- Added Gmail channel creation:
- ```
- const gmail = new GmailChannel(channelOpts);
- channels.push(gmail);
- await gmail.connect();
- ```
-- Gmail uses the same `channelOpts` callbacks as other channels
-- Incoming emails are delivered to the main group (agent decides how to respond, user can configure)
-
-## Invariants
-
-- All existing message processing logic (triggers, cursors, idle timers) is preserved
-- The `runAgent` function is completely unchanged
-- State management (loadState/saveState) is unchanged
-- Recovery logic is unchanged
-- Container runtime check is unchanged
-- Any other channel creation is untouched
-- Shutdown iterates `channels` array (Gmail is included automatically)
-
-## Must-keep
-
-- The `escapeXml` and `formatMessages` re-exports
-- The `_setRegisteredGroups` test helper
-- The `isDirectRun` guard at bottom
-- All error handling and cursor rollback logic in processGroupMessages
-- The outgoing queue flush and reconnection logic
diff --git a/.claude/skills/add-gmail/modify/src/routing.test.ts b/.claude/skills/add-gmail/modify/src/routing.test.ts
deleted file mode 100644
index 837b1da..0000000
--- a/.claude/skills/add-gmail/modify/src/routing.test.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-
-import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
-import { getAvailableGroups, _setRegisteredGroups } from './index.js';
-
-beforeEach(() => {
- _initTestDatabase();
- _setRegisteredGroups({});
-});
-
-// --- JID ownership patterns ---
-
-describe('JID ownership patterns', () => {
- // These test the patterns that will become ownsJid() on the Channel interface
-
- it('WhatsApp group JID: ends with @g.us', () => {
- const jid = '12345678@g.us';
- expect(jid.endsWith('@g.us')).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);
- });
-
- it('Gmail JID: starts with gmail:', () => {
- const jid = 'gmail:abc123def';
- expect(jid.startsWith('gmail:')).toBe(true);
- });
-
- it('Gmail thread JID: starts with gmail: followed by thread ID', () => {
- const jid = 'gmail:18d3f4a5b6c7d8e9';
- expect(jid.startsWith('gmail:')).toBe(true);
- });
-});
-
-// --- getAvailableGroups ---
-
-describe('getAvailableGroups', () => {
- it('returns only groups, excludes DMs', () => {
- storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
- storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
- storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.jid)).toContain('group1@g.us');
- expect(groups.map((g) => g.jid)).toContain('group2@g.us');
- expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
- });
-
- it('excludes __group_sync__ sentinel', () => {
- storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
- storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('marks registered groups correctly', () => {
- storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
- storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
-
- _setRegisteredGroups({
- 'reg@g.us': {
- name: 'Registered',
- folder: 'registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const reg = groups.find((g) => g.jid === 'reg@g.us');
- const unreg = groups.find((g) => g.jid === 'unreg@g.us');
-
- expect(reg?.isRegistered).toBe(true);
- expect(unreg?.isRegistered).toBe(false);
- });
-
- it('returns groups ordered by most recent activity', () => {
- storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
- storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
- storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups[0].jid).toBe('new@g.us');
- expect(groups[1].jid).toBe('mid@g.us');
- expect(groups[2].jid).toBe('old@g.us');
- });
-
- 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');
- // Explicitly non-group with unusual JID
- storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
- // A real group for contrast
- storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('returns empty array when no chats exist', () => {
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(0);
- });
-
- it('excludes Gmail threads from group list (Gmail threads are not groups)', () => {
- storeChatMetadata('gmail:abc123', '2024-01-01T00:00:01.000Z', 'Email thread', 'gmail', false);
- storeChatMetadata('group@g.us', '2024-01-01T00:00:02.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-});
diff --git a/.claude/skills/add-gmail/tests/gmail.test.ts b/.claude/skills/add-gmail/tests/gmail.test.ts
index 02d9721..79e8ecb 100644
--- a/.claude/skills/add-gmail/tests/gmail.test.ts
+++ b/.claude/skills/add-gmail/tests/gmail.test.ts
@@ -2,39 +2,97 @@ import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
-const root = process.cwd();
-const read = (f: string) => fs.readFileSync(path.join(root, f), 'utf-8');
+describe('add-gmail skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
-function getGmailMode(): 'tool-only' | 'channel' {
- const p = path.join(root, '.nanoclaw/state.yaml');
- if (!fs.existsSync(p)) return 'channel';
- return read('.nanoclaw/state.yaml').includes('mode: tool-only') ? 'tool-only' : 'channel';
-}
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
-const mode = getGmailMode();
-const channelOnly = mode === 'tool-only';
-
-describe('add-gmail skill', () => {
- it('container-runner mounts ~/.gmail-mcp', () => {
- expect(read('src/container-runner.ts')).toContain('.gmail-mcp');
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: gmail');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('googleapis');
});
- it('agent-runner has gmail MCP server', () => {
- const content = read('container/agent-runner/src/index.ts');
+ it('has channel file with self-registration', () => {
+ const channelFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'gmail.ts',
+ );
+ expect(fs.existsSync(channelFile)).toBe(true);
+
+ const content = fs.readFileSync(channelFile, 'utf-8');
+ expect(content).toContain('class GmailChannel');
+ expect(content).toContain('implements Channel');
+ expect(content).toContain("registerChannel('gmail'");
+ });
+
+ it('has channel barrel file modification', () => {
+ const indexFile = path.join(
+ skillDir,
+ 'modify',
+ 'src',
+ 'channels',
+ 'index.ts',
+ );
+ expect(fs.existsSync(indexFile)).toBe(true);
+
+ const indexContent = fs.readFileSync(indexFile, 'utf-8');
+ expect(indexContent).toContain("import './gmail.js'");
+ });
+
+ it('has intent files for modified files', () => {
+ expect(
+ fs.existsSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
+ ),
+ ).toBe(true);
+ });
+
+ it('has container-runner mount modification', () => {
+ const crFile = path.join(
+ skillDir,
+ 'modify',
+ 'src',
+ 'container-runner.ts',
+ );
+ expect(fs.existsSync(crFile)).toBe(true);
+
+ const content = fs.readFileSync(crFile, 'utf-8');
+ expect(content).toContain('.gmail-mcp');
+ });
+
+ it('has agent-runner Gmail MCP server modification', () => {
+ const arFile = path.join(
+ skillDir,
+ 'modify',
+ 'container',
+ 'agent-runner',
+ 'src',
+ 'index.ts',
+ );
+ expect(fs.existsSync(arFile)).toBe(true);
+
+ const content = fs.readFileSync(arFile, 'utf-8');
expect(content).toContain('mcp__gmail__*');
expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp');
});
- it.skipIf(channelOnly)('gmail channel file exists', () => {
- expect(fs.existsSync(path.join(root, 'src/channels/gmail.ts'))).toBe(true);
- });
+ it('has test file for the channel', () => {
+ const testFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'gmail.test.ts',
+ );
+ expect(fs.existsSync(testFile)).toBe(true);
- it.skipIf(channelOnly)('index.ts wires up GmailChannel', () => {
- expect(read('src/index.ts')).toContain('GmailChannel');
- });
-
- it.skipIf(channelOnly)('googleapis dependency installed', () => {
- const pkg = JSON.parse(read('package.json'));
- expect(pkg.dependencies?.googleapis || pkg.devDependencies?.googleapis).toBeDefined();
+ const testContent = fs.readFileSync(testFile, 'utf-8');
+ expect(testContent).toContain("describe('GmailChannel'");
});
});
diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md
index 3914bd9..416c778 100644
--- a/.claude/skills/add-slack/SKILL.md
+++ b/.claude/skills/add-slack/SKILL.md
@@ -15,11 +15,7 @@ Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, skip to Phase 3
### Ask the user
-1. **Mode**: Replace WhatsApp or add alongside it?
- - Replace → will set `SLACK_ONLY=true`
- - Alongside → both channels active (default)
-
-2. **Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3.
+**Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3.
## Phase 2: Apply Code Changes
@@ -42,19 +38,14 @@ npx tsx scripts/apply-skill.ts .claude/skills/add-slack
```
This deterministically:
-- Adds `src/channels/slack.ts` (SlackChannel class implementing Channel interface)
+- Adds `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
- Adds `src/channels/slack.test.ts` (46 unit tests)
-- Three-way merges Slack support into `src/index.ts` (multi-channel support, conditional channel creation)
-- Three-way merges Slack config into `src/config.ts` (SLACK_ONLY export)
-- Three-way merges updated routing tests into `src/routing.test.ts`
+- Appends `import './slack.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `@slack/bolt` npm dependency
-- Updates `.env.example` with `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ONLY`
- Records the application in `.nanoclaw/state.yaml`
-If the apply reports merge conflicts, read the intent files:
-- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
-- `modify/src/config.ts.intent.md` — what changed for config.ts
-- `modify/src/routing.test.ts.intent.md` — what changed for routing tests
+If the apply reports merge conflicts, read the intent file:
+- `modify/src/channels/index.ts.intent.md` — what changed and invariants
### Validate code changes
@@ -89,11 +80,7 @@ SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-token
```
-If they chose to replace WhatsApp:
-
-```bash
-SLACK_ONLY=true
-```
+Channels auto-enable when their credentials are present — no extra configuration needed.
Sync to container environment:
@@ -128,15 +115,16 @@ Wait for the user to provide the channel ID.
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
-For a main channel (responds to all messages, uses the `main` folder):
+For a main channel (responds to all messages):
```typescript
registerGroup("slack:", {
name: "",
- folder: "main",
+ folder: "slack_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
+ isMain: true,
});
```
@@ -145,7 +133,7 @@ For additional channels (trigger-only):
```typescript
registerGroup("slack:", {
name: "",
- folder: "",
+ folder: "slack_",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
@@ -215,7 +203,7 @@ The Slack channel supports:
- **Public channels** — Bot must be added to the channel
- **Private channels** — Bot must be invited to the channel
- **Direct messages** — Users can DM the bot directly
-- **Multi-channel** — Can run alongside WhatsApp (default) or replace it (`SLACK_ONLY=true`)
+- **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials)
## Known Limitations
diff --git a/.claude/skills/add-slack/add/src/channels/slack.test.ts b/.claude/skills/add-slack/add/src/channels/slack.test.ts
index 4c841d1..241d09a 100644
--- a/.claude/skills/add-slack/add/src/channels/slack.test.ts
+++ b/.claude/skills/add-slack/add/src/channels/slack.test.ts
@@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
+// Mock registry (registerChannel runs at import time)
+vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
+
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Jonesy',
diff --git a/.claude/skills/add-slack/add/src/channels/slack.ts b/.claude/skills/add-slack/add/src/channels/slack.ts
index 81cc1ac..c783240 100644
--- a/.claude/skills/add-slack/add/src/channels/slack.ts
+++ b/.claude/skills/add-slack/add/src/channels/slack.ts
@@ -5,6 +5,7 @@ import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { updateChatName } from '../db.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
+import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnInboundMessage,
@@ -288,3 +289,12 @@ export class SlackChannel implements Channel {
}
}
}
+
+registerChannel('slack', (opts: ChannelOpts) => {
+ const envVars = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
+ if (!envVars.SLACK_BOT_TOKEN || !envVars.SLACK_APP_TOKEN) {
+ logger.warn('Slack: SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set');
+ return null;
+ }
+ return new SlackChannel(opts);
+});
diff --git a/.claude/skills/add-slack/manifest.yaml b/.claude/skills/add-slack/manifest.yaml
index 8320bb3..80cec1e 100644
--- a/.claude/skills/add-slack/manifest.yaml
+++ b/.claude/skills/add-slack/manifest.yaml
@@ -6,16 +6,13 @@ adds:
- src/channels/slack.ts
- src/channels/slack.test.ts
modifies:
- - src/index.ts
- - src/config.ts
- - src/routing.test.ts
+ - src/channels/index.ts
structured:
npm_dependencies:
"@slack/bolt": "^4.6.0"
env_additions:
- SLACK_BOT_TOKEN
- SLACK_APP_TOKEN
- - SLACK_ONLY
conflicts: []
depends: []
test: "npx vitest run src/channels/slack.test.ts"
diff --git a/.claude/skills/add-slack/modify/src/channels/index.ts b/.claude/skills/add-slack/modify/src/channels/index.ts
new file mode 100644
index 0000000..e8118a7
--- /dev/null
+++ b/.claude/skills/add-slack/modify/src/channels/index.ts
@@ -0,0 +1,13 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+
+// slack
+import './slack.js';
+
+// telegram
+
+// whatsapp
diff --git a/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md b/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md
new file mode 100644
index 0000000..51ccb1c
--- /dev/null
+++ b/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md
@@ -0,0 +1,7 @@
+# Intent: Add Slack channel import
+
+Add `import './slack.js';` to the channel barrel file so the Slack
+module self-registers with the channel registry on startup.
+
+This is an append-only change — existing import lines for other channels
+must be preserved.
diff --git a/.claude/skills/add-slack/modify/src/config.ts b/.claude/skills/add-slack/modify/src/config.ts
deleted file mode 100644
index 1b59cf7..0000000
--- a/.claude/skills/add-slack/modify/src/config.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import path from 'path';
-
-import { readEnvFile } from './env.js';
-
-// Read config values from .env (falls back to process.env).
-// Secrets are NOT read here — they stay on disk and are loaded only
-// where needed (container-runner.ts) to avoid leaking to child processes.
-const envConfig = readEnvFile([
- 'ASSISTANT_NAME',
- 'ASSISTANT_HAS_OWN_NUMBER',
- 'SLACK_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 POLL_INTERVAL = 2000;
-export const SCHEDULER_POLL_INTERVAL = 60000;
-
-// Absolute paths needed for container mounts
-const PROJECT_ROOT = process.cwd();
-const HOME_DIR = process.env.HOME || '/Users/user';
-
-// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
-export const MOUNT_ALLOWLIST_PATH = path.join(
- HOME_DIR,
- '.config',
- 'nanoclaw',
- 'mount-allowlist.json',
-);
-export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
-export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
-export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
-export const MAIN_GROUP_FOLDER = 'main';
-
-export const CONTAINER_IMAGE =
- process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
-export const CONTAINER_TIMEOUT = parseInt(
- process.env.CONTAINER_TIMEOUT || '1800000',
- 10,
-);
-export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
- process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
- 10,
-); // 10MB default
-export const IPC_POLL_INTERVAL = 1000;
-export const IDLE_TIMEOUT = parseInt(
- process.env.IDLE_TIMEOUT || '1800000',
- 10,
-); // 30min default — how long to keep container alive after last result
-export const MAX_CONCURRENT_CONTAINERS = Math.max(
- 1,
- parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
-);
-
-function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
-export const TRIGGER_PATTERN = new RegExp(
- `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
- 'i',
-);
-
-// Timezone for scheduled tasks (cron expressions, etc.)
-// Uses system timezone by default
-export const TIMEZONE =
- process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
-
-// Slack configuration
-// SLACK_BOT_TOKEN and SLACK_APP_TOKEN are read directly by SlackChannel
-// from .env via readEnvFile() to keep secrets off process.env.
-export const SLACK_ONLY =
- (process.env.SLACK_ONLY || envConfig.SLACK_ONLY) === 'true';
diff --git a/.claude/skills/add-slack/modify/src/config.ts.intent.md b/.claude/skills/add-slack/modify/src/config.ts.intent.md
deleted file mode 100644
index b23def4..0000000
--- a/.claude/skills/add-slack/modify/src/config.ts.intent.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Intent: src/config.ts modifications
-
-## What changed
-Added SLACK_ONLY configuration export for Slack channel support.
-
-## Key sections
-- **readEnvFile call**: Must include `SLACK_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
-- **SLACK_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
-- **Note**: SLACK_BOT_TOKEN and SLACK_APP_TOKEN are NOT read here. They are read directly by SlackChannel via `readEnvFile()` in `slack.ts` to keep secrets off the config module entirely (same pattern as ANTHROPIC_API_KEY in container-runner.ts).
-
-## Invariants
-- All existing config exports remain unchanged
-- New Slack key is added to the `readEnvFile` call alongside existing keys
-- New export is appended at the end of the file
-- No existing behavior is modified — Slack config is additive only
-- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
-
-## Must-keep
-- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
-- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
-- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.claude/skills/add-slack/modify/src/index.ts b/.claude/skills/add-slack/modify/src/index.ts
deleted file mode 100644
index 50212e1..0000000
--- a/.claude/skills/add-slack/modify/src/index.ts
+++ /dev/null
@@ -1,498 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-import {
- ASSISTANT_NAME,
- DATA_DIR,
- IDLE_TIMEOUT,
- MAIN_GROUP_FOLDER,
- POLL_INTERVAL,
- SLACK_ONLY,
- TRIGGER_PATTERN,
-} from './config.js';
-import { WhatsAppChannel } from './channels/whatsapp.js';
-import { SlackChannel } from './channels/slack.js';
-import {
- ContainerOutput,
- runContainerAgent,
- writeGroupsSnapshot,
- writeTasksSnapshot,
-} from './container-runner.js';
-import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
-import {
- getAllChats,
- getAllRegisteredGroups,
- getAllSessions,
- getAllTasks,
- getMessagesSince,
- getNewMessages,
- getRouterState,
- initDatabase,
- setRegisteredGroup,
- setRouterState,
- setSession,
- storeChatMetadata,
- storeMessage,
-} from './db.js';
-import { GroupQueue } from './group-queue.js';
-import { startIpcWatcher } from './ipc.js';
-import { findChannel, formatMessages, formatOutbound } from './router.js';
-import { startSchedulerLoop } from './task-scheduler.js';
-import { Channel, NewMessage, RegisteredGroup } from './types.js';
-import { logger } from './logger.js';
-import { readEnvFile } from './env.js';
-
-// Re-export for backwards compatibility during refactor
-export { escapeXml, formatMessages } from './router.js';
-
-let lastTimestamp = '';
-let sessions: Record = {};
-let registeredGroups: Record = {};
-let lastAgentTimestamp: Record = {};
-let messageLoopRunning = false;
-
-let whatsapp: WhatsAppChannel;
-let slack: SlackChannel | undefined;
-const channels: Channel[] = [];
-const queue = new GroupQueue();
-
-function loadState(): void {
- lastTimestamp = getRouterState('last_timestamp') || '';
- const agentTs = getRouterState('last_agent_timestamp');
- try {
- lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
- } catch {
- logger.warn('Corrupted last_agent_timestamp in DB, resetting');
- lastAgentTimestamp = {};
- }
- sessions = getAllSessions();
- registeredGroups = getAllRegisteredGroups();
- logger.info(
- { groupCount: Object.keys(registeredGroups).length },
- 'State loaded',
- );
-}
-
-function saveState(): void {
- setRouterState('last_timestamp', lastTimestamp);
- setRouterState(
- 'last_agent_timestamp',
- JSON.stringify(lastAgentTimestamp),
- );
-}
-
-function registerGroup(jid: string, group: RegisteredGroup): void {
- registeredGroups[jid] = group;
- setRegisteredGroup(jid, group);
-
- // Create group folder
- const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
-
- logger.info(
- { jid, name: group.name, folder: group.folder },
- 'Group registered',
- );
-}
-
-/**
- * Get available groups list for the agent.
- * Returns groups ordered by most recent activity.
- */
-export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
- const chats = getAllChats();
- const registeredJids = new Set(Object.keys(registeredGroups));
-
- return chats
- .filter((c) => c.jid !== '__group_sync__' && c.is_group)
- .map((c) => ({
- jid: c.jid,
- name: c.name,
- lastActivity: c.last_message_time,
- isRegistered: registeredJids.has(c.jid),
- }));
-}
-
-/** @internal - exported for testing */
-export function _setRegisteredGroups(groups: Record): void {
- registeredGroups = groups;
-}
-
-/**
- * Process all pending messages for a group.
- * Called by the GroupQueue when it's this group's turn.
- */
-async function processGroupMessages(chatJid: string): Promise {
- const group = registeredGroups[chatJid];
- if (!group) return true;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- return true;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
-
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
-
- if (missedMessages.length === 0) return true;
-
- // For non-main groups, check if trigger is required and present
- if (!isMainGroup && group.requiresTrigger !== false) {
- const hasTrigger = missedMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) return true;
- }
-
- const prompt = formatMessages(missedMessages);
-
- // Advance cursor so the piping path in startMessageLoop won't re-fetch
- // these messages. Save the old cursor so we can roll back on error.
- const previousCursor = lastAgentTimestamp[chatJid] || '';
- lastAgentTimestamp[chatJid] =
- missedMessages[missedMessages.length - 1].timestamp;
- saveState();
-
- logger.info(
- { group: group.name, messageCount: missedMessages.length },
- 'Processing messages',
- );
-
- // Track idle timer for closing stdin when agent is idle
- let idleTimer: ReturnType | null = null;
-
- const resetIdleTimer = () => {
- if (idleTimer) clearTimeout(idleTimer);
- idleTimer = setTimeout(() => {
- logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
- queue.closeStdin(chatJid);
- }, IDLE_TIMEOUT);
- };
-
- await channel.setTyping?.(chatJid, true);
- let hadError = false;
- let outputSentToUser = false;
-
- const output = await runAgent(group, prompt, chatJid, async (result) => {
- // Streaming output callback — called for each agent result
- if (result.result) {
- const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
- // Strip ... blocks — agent uses these for internal reasoning
- const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
- logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
- if (text) {
- await channel.sendMessage(chatJid, text);
- outputSentToUser = true;
- }
- // Only reset idle timer on actual results, not session-update markers (result: null)
- resetIdleTimer();
- }
-
- if (result.status === 'error') {
- hadError = true;
- }
- });
-
- await channel.setTyping?.(chatJid, false);
- if (idleTimer) clearTimeout(idleTimer);
-
- if (output === 'error' || hadError) {
- // If we already sent output to the user, don't roll back the cursor —
- // the user got their response and re-processing would send duplicates.
- if (outputSentToUser) {
- logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
- return true;
- }
- // Roll back cursor so retries can re-process these messages
- lastAgentTimestamp[chatJid] = previousCursor;
- saveState();
- logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
- return false;
- }
-
- return true;
-}
-
-async function runAgent(
- group: RegisteredGroup,
- prompt: string,
- chatJid: string,
- onOutput?: (output: ContainerOutput) => Promise,
-): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
- const sessionId = sessions[group.folder];
-
- // Update tasks snapshot for container to read (filtered by group)
- const tasks = getAllTasks();
- writeTasksSnapshot(
- group.folder,
- isMain,
- tasks.map((t) => ({
- id: t.id,
- groupFolder: t.group_folder,
- prompt: t.prompt,
- schedule_type: t.schedule_type,
- schedule_value: t.schedule_value,
- status: t.status,
- next_run: t.next_run,
- })),
- );
-
- // Update available groups snapshot (main group only can see all groups)
- const availableGroups = getAvailableGroups();
- writeGroupsSnapshot(
- group.folder,
- isMain,
- availableGroups,
- new Set(Object.keys(registeredGroups)),
- );
-
- // Wrap onOutput to track session ID from streamed results
- const wrappedOnOutput = onOutput
- ? async (output: ContainerOutput) => {
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
- await onOutput(output);
- }
- : undefined;
-
- try {
- const output = await runContainerAgent(
- group,
- {
- prompt,
- sessionId,
- groupFolder: group.folder,
- chatJid,
- isMain,
- },
- (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
- wrappedOnOutput,
- );
-
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
-
- if (output.status === 'error') {
- logger.error(
- { group: group.name, error: output.error },
- 'Container agent error',
- );
- return 'error';
- }
-
- return 'success';
- } catch (err) {
- logger.error({ group: group.name, err }, 'Agent error');
- return 'error';
- }
-}
-
-async function startMessageLoop(): Promise {
- if (messageLoopRunning) {
- logger.debug('Message loop already running, skipping duplicate start');
- return;
- }
- messageLoopRunning = true;
-
- logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
-
- while (true) {
- try {
- const jids = Object.keys(registeredGroups);
- const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
-
- if (messages.length > 0) {
- logger.info({ count: messages.length }, 'New messages');
-
- // Advance the "seen" cursor for all messages immediately
- lastTimestamp = newTimestamp;
- saveState();
-
- // Deduplicate by group
- const messagesByGroup = new Map();
- for (const msg of messages) {
- const existing = messagesByGroup.get(msg.chat_jid);
- if (existing) {
- existing.push(msg);
- } else {
- messagesByGroup.set(msg.chat_jid, [msg]);
- }
- }
-
- for (const [chatJid, groupMessages] of messagesByGroup) {
- const group = registeredGroups[chatJid];
- if (!group) continue;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- continue;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
- const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
-
- // For non-main groups, only act on trigger messages.
- // Non-trigger messages accumulate in DB and get pulled as
- // context when a trigger eventually arrives.
- if (needsTrigger) {
- const hasTrigger = groupMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) continue;
- }
-
- // Pull all messages since lastAgentTimestamp so non-trigger
- // context that accumulated between triggers is included.
- const allPending = getMessagesSince(
- chatJid,
- lastAgentTimestamp[chatJid] || '',
- ASSISTANT_NAME,
- );
- const messagesToSend =
- allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
-
- if (queue.sendMessage(chatJid, formatted)) {
- logger.debug(
- { chatJid, count: messagesToSend.length },
- 'Piped messages to active container',
- );
- lastAgentTimestamp[chatJid] =
- messagesToSend[messagesToSend.length - 1].timestamp;
- saveState();
- // Show typing indicator while the container processes the piped message
- channel.setTyping?.(chatJid, true);
- } else {
- // No active container — enqueue for a new one
- queue.enqueueMessageCheck(chatJid);
- }
- }
- }
- } catch (err) {
- logger.error({ err }, 'Error in message loop');
- }
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
- }
-}
-
-/**
- * Startup recovery: check for unprocessed messages in registered groups.
- * Handles crash between advancing lastTimestamp and processing messages.
- */
-function recoverPendingMessages(): void {
- for (const [chatJid, group] of Object.entries(registeredGroups)) {
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
- if (pending.length > 0) {
- logger.info(
- { group: group.name, pendingCount: pending.length },
- 'Recovery: found unprocessed messages',
- );
- queue.enqueueMessageCheck(chatJid);
- }
- }
-}
-
-function ensureContainerSystemRunning(): void {
- ensureContainerRuntimeRunning();
- cleanupOrphans();
-}
-
-async function main(): Promise {
- ensureContainerSystemRunning();
- initDatabase();
- logger.info('Database initialized');
- loadState();
-
- // Graceful shutdown handlers
- const shutdown = async (signal: string) => {
- logger.info({ signal }, 'Shutdown signal received');
- await queue.shutdown(10000);
- for (const ch of channels) await ch.disconnect();
- process.exit(0);
- };
- process.on('SIGTERM', () => shutdown('SIGTERM'));
- process.on('SIGINT', () => shutdown('SIGINT'));
-
- // Channel callbacks (shared by all channels)
- const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
- onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
- storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
- registeredGroups: () => registeredGroups,
- };
-
- // Create and connect channels
- // Check if Slack tokens are configured
- const slackEnv = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
- const hasSlackTokens = !!(slackEnv.SLACK_BOT_TOKEN && slackEnv.SLACK_APP_TOKEN);
-
- if (!SLACK_ONLY) {
- whatsapp = new WhatsAppChannel(channelOpts);
- channels.push(whatsapp);
- await whatsapp.connect();
- }
-
- if (hasSlackTokens) {
- slack = new SlackChannel(channelOpts);
- channels.push(slack);
- await slack.connect();
- }
-
- // Start subsystems (independently of connection handler)
- startSchedulerLoop({
- registeredGroups: () => registeredGroups,
- getSessions: () => sessions,
- queue,
- 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;
- }
- const text = formatOutbound(rawText);
- if (text) await channel.sendMessage(jid, text);
- },
- });
- startIpcWatcher({
- sendMessage: (jid, text) => {
- const channel = findChannel(channels, jid);
- if (!channel) throw new Error(`No channel for JID: ${jid}`);
- return channel.sendMessage(jid, text);
- },
- registeredGroups: () => registeredGroups,
- registerGroup,
- syncGroupMetadata: async (force) => {
- // Sync metadata across all active channels
- if (whatsapp) await whatsapp.syncGroupMetadata(force);
- if (slack) await slack.syncChannelMetadata();
- },
- getAvailableGroups,
- writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
- });
- queue.setProcessMessagesFn(processGroupMessages);
- recoverPendingMessages();
- startMessageLoop();
-}
-
-// Guard: only run when executed directly, not when imported by tests
-const isDirectRun =
- process.argv[1] &&
- new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
-
-if (isDirectRun) {
- main().catch((err) => {
- logger.error({ err }, 'Failed to start NanoClaw');
- process.exit(1);
- });
-}
diff --git a/.claude/skills/add-slack/modify/src/index.ts.intent.md b/.claude/skills/add-slack/modify/src/index.ts.intent.md
deleted file mode 100644
index 8412843..0000000
--- a/.claude/skills/add-slack/modify/src/index.ts.intent.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Intent: src/index.ts modifications
-
-## What changed
-Refactored from single WhatsApp channel to multi-channel architecture supporting Slack alongside WhatsApp.
-
-## Key sections
-
-### Imports (top of file)
-- Added: `SlackChannel` from `./channels/slack.js`
-- Added: `SLACK_ONLY` from `./config.js`
-- Added: `readEnvFile` from `./env.js`
-- Existing: `findChannel` from `./router.js` and `Channel` type from `./types.js` are already present
-
-### Module-level state
-- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference
-- Added: `let slack: SlackChannel | undefined` — direct reference for `syncChannelMetadata`
-- Kept: `const channels: Channel[] = []` — array of all active channels
-
-### processGroupMessages()
-- Uses `findChannel(channels, chatJid)` lookup (already exists in base)
-- Uses `channel.setTyping?.()` and `channel.sendMessage()` (already exists in base)
-
-### startMessageLoop()
-- Uses `findChannel(channels, chatJid)` per group (already exists in base)
-- Uses `channel.setTyping?.()` for typing indicators (already exists in base)
-
-### main()
-- Added: Reads Slack tokens via `readEnvFile()` to check if Slack is configured
-- Added: conditional WhatsApp creation (`if (!SLACK_ONLY)`)
-- Added: conditional Slack creation (`if (hasSlackTokens)`)
-- Changed: scheduler `sendMessage` uses `findChannel()` → `channel.sendMessage()`
-- Changed: IPC `syncGroupMetadata` syncs both WhatsApp and Slack metadata
-- Changed: IPC `sendMessage` uses `findChannel()` → `channel.sendMessage()`
-
-### Shutdown handler
-- Changed from `await whatsapp.disconnect()` to `for (const ch of channels) await ch.disconnect()`
-- Disconnects all active channels (WhatsApp, Slack, or any future channels) on SIGTERM/SIGINT
-
-## Invariants
-- All existing message processing logic (triggers, cursors, idle timers) is preserved
-- The `runAgent` function is completely unchanged
-- State management (loadState/saveState) is unchanged
-- Recovery logic is unchanged
-- Container runtime check is unchanged (ensureContainerSystemRunning)
-
-## Design decisions
-
-### Double readEnvFile for Slack tokens
-`main()` in index.ts reads `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` via `readEnvFile()` to check
-whether Slack is configured (controls whether to instantiate SlackChannel). The SlackChannel
-constructor reads them again independently. This is intentional — index.ts needs to decide
-*whether* to create the channel, while SlackChannel needs the actual token values. Keeping
-both reads follows the security pattern of not passing secrets through intermediate variables.
-
-## Must-keep
-- The `escapeXml` and `formatMessages` re-exports
-- The `_setRegisteredGroups` test helper
-- The `isDirectRun` guard at bottom
-- All error handling and cursor rollback logic in processGroupMessages
-- The outgoing queue flush and reconnection logic (in each channel, not here)
diff --git a/.claude/skills/add-slack/modify/src/routing.test.ts b/.claude/skills/add-slack/modify/src/routing.test.ts
deleted file mode 100644
index 3a7f7ff..0000000
--- a/.claude/skills/add-slack/modify/src/routing.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-
-import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
-import { getAvailableGroups, _setRegisteredGroups } from './index.js';
-
-beforeEach(() => {
- _initTestDatabase();
- _setRegisteredGroups({});
-});
-
-// --- JID ownership patterns ---
-
-describe('JID ownership patterns', () => {
- // These test the patterns that will become ownsJid() on the Channel interface
-
- it('WhatsApp group JID: ends with @g.us', () => {
- const jid = '12345678@g.us';
- expect(jid.endsWith('@g.us')).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);
- });
-
- it('Slack channel JID: starts with slack:', () => {
- const jid = 'slack:C0123456789';
- expect(jid.startsWith('slack:')).toBe(true);
- });
-
- it('Slack DM JID: starts with slack:D', () => {
- const jid = 'slack:D0123456789';
- expect(jid.startsWith('slack:')).toBe(true);
- });
-});
-
-// --- getAvailableGroups ---
-
-describe('getAvailableGroups', () => {
- it('returns only groups, excludes DMs', () => {
- storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
- storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
- storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.jid)).toContain('group1@g.us');
- expect(groups.map((g) => g.jid)).toContain('group2@g.us');
- expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
- });
-
- it('excludes __group_sync__ sentinel', () => {
- storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
- storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('marks registered groups correctly', () => {
- storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
- storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
-
- _setRegisteredGroups({
- 'reg@g.us': {
- name: 'Registered',
- folder: 'registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const reg = groups.find((g) => g.jid === 'reg@g.us');
- const unreg = groups.find((g) => g.jid === 'unreg@g.us');
-
- expect(reg?.isRegistered).toBe(true);
- expect(unreg?.isRegistered).toBe(false);
- });
-
- it('returns groups ordered by most recent activity', () => {
- storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
- storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
- storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups[0].jid).toBe('new@g.us');
- expect(groups[1].jid).toBe('mid@g.us');
- expect(groups[2].jid).toBe('old@g.us');
- });
-
- 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');
- // Explicitly non-group with unusual JID
- storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
- // A real group for contrast
- storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('returns empty array when no chats exist', () => {
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(0);
- });
-
- it('includes Slack channel JIDs', () => {
- storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Channel', 'slack', 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('slack:C0123456789');
- });
-
- it('returns Slack DM JIDs as groups when is_group is true', () => {
- storeChatMetadata('slack:D0123456789', '2024-01-01T00:00:01.000Z', 'Slack DM', 'slack', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('slack:D0123456789');
- expect(groups[0].name).toBe('Slack DM');
- });
-
- it('marks registered Slack channels correctly', () => {
- storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Registered', 'slack', true);
- storeChatMetadata('slack:C9999999999', '2024-01-01T00:00:02.000Z', 'Slack Unregistered', 'slack', true);
-
- _setRegisteredGroups({
- 'slack:C0123456789': {
- name: 'Slack Registered',
- folder: 'slack-registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const slackReg = groups.find((g) => g.jid === 'slack:C0123456789');
- const slackUnreg = groups.find((g) => g.jid === 'slack:C9999999999');
-
- expect(slackReg?.isRegistered).toBe(true);
- expect(slackUnreg?.isRegistered).toBe(false);
- });
-
- it('mixes WhatsApp and Slack chats ordered by activity', () => {
- storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
- storeChatMetadata('slack:C100', '2024-01-01T00:00:03.000Z', 'Slack', 'slack', 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('slack:C100');
- expect(groups[1].jid).toBe('wa2@g.us');
- expect(groups[2].jid).toBe('wa@g.us');
- });
-});
diff --git a/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md b/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md
deleted file mode 100644
index a03ba99..0000000
--- a/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Intent: src/routing.test.ts modifications
-
-## What changed
-Added Slack JID pattern tests and Slack-specific getAvailableGroups tests.
-
-## Key sections
-- **JID ownership patterns**: Added Slack channel JID (`slack:C...`) and Slack DM JID (`slack:D...`) pattern tests
-- **getAvailableGroups**: Added tests for Slack channel inclusion, Slack DM handling, registered Slack channels, and mixed WhatsApp + Slack ordering
-
-## Invariants
-- All existing WhatsApp JID pattern tests remain unchanged
-- All existing getAvailableGroups tests remain unchanged
-- New tests follow the same patterns as existing tests
-
-## Must-keep
-- All existing WhatsApp tests (group JID, DM JID patterns)
-- All existing getAvailableGroups tests (DM exclusion, sentinel exclusion, registration, ordering, non-group exclusion, empty array)
diff --git a/.claude/skills/add-slack/tests/slack.test.ts b/.claude/skills/add-slack/tests/slack.test.ts
index 7e8d946..320a8cc 100644
--- a/.claude/skills/add-slack/tests/slack.test.ts
+++ b/.claude/skills/add-slack/tests/slack.test.ts
@@ -16,15 +16,28 @@ describe('slack skill package', () => {
});
it('has all files declared in adds', () => {
- const addFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.ts');
- expect(fs.existsSync(addFile)).toBe(true);
+ const channelFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'slack.ts',
+ );
+ expect(fs.existsSync(channelFile)).toBe(true);
- const content = fs.readFileSync(addFile, 'utf-8');
+ const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class SlackChannel');
expect(content).toContain('implements Channel');
+ expect(content).toContain("registerChannel('slack'");
// Test file for the channel
- const testFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.test.ts');
+ const testFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'slack.test.ts',
+ );
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
@@ -32,28 +45,26 @@ describe('slack skill package', () => {
});
it('has all files declared in modifies', () => {
- const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
- const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
- const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
-
+ // Channel barrel file
+ const indexFile = path.join(
+ skillDir,
+ 'modify',
+ 'src',
+ 'channels',
+ 'index.ts',
+ );
expect(fs.existsSync(indexFile)).toBe(true);
- expect(fs.existsSync(configFile)).toBe(true);
- expect(fs.existsSync(routingTestFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
- expect(indexContent).toContain('SlackChannel');
- expect(indexContent).toContain('SLACK_ONLY');
- expect(indexContent).toContain('findChannel');
- expect(indexContent).toContain('channels: Channel[]');
-
- const configContent = fs.readFileSync(configFile, 'utf-8');
- expect(configContent).toContain('SLACK_ONLY');
+ expect(indexContent).toContain("import './slack.js'");
});
it('has intent files for modified files', () => {
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'routing.test.ts.intent.md'))).toBe(true);
+ expect(
+ fs.existsSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
+ ),
+ ).toBe(true);
});
it('has setup documentation', () => {
@@ -61,87 +72,6 @@ describe('slack skill package', () => {
expect(fs.existsSync(path.join(skillDir, 'SLACK_SETUP.md'))).toBe(true);
});
- it('modified index.ts preserves core structure', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Core functions still present
- expect(content).toContain('function loadState()');
- expect(content).toContain('function saveState()');
- expect(content).toContain('function registerGroup(');
- expect(content).toContain('function getAvailableGroups()');
- expect(content).toContain('function processGroupMessages(');
- expect(content).toContain('function runAgent(');
- expect(content).toContain('function startMessageLoop()');
- expect(content).toContain('function recoverPendingMessages()');
- expect(content).toContain('function ensureContainerSystemRunning()');
- expect(content).toContain('async function main()');
-
- // Test helper preserved
- expect(content).toContain('_setRegisteredGroups');
-
- // Direct-run guard preserved
- expect(content).toContain('isDirectRun');
- });
-
- it('modified index.ts includes Slack channel creation', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Multi-channel architecture
- expect(content).toContain('const channels: Channel[] = []');
- expect(content).toContain('channels.push(whatsapp)');
- expect(content).toContain('channels.push(slack)');
-
- // Conditional channel creation
- expect(content).toContain('if (!SLACK_ONLY)');
- expect(content).toContain('new SlackChannel(channelOpts)');
-
- // Shutdown disconnects all channels
- expect(content).toContain('for (const ch of channels) await ch.disconnect()');
- });
-
- it('modified config.ts preserves all existing exports', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'config.ts'),
- 'utf-8',
- );
-
- // All original exports preserved
- expect(content).toContain('export const ASSISTANT_NAME');
- expect(content).toContain('export const POLL_INTERVAL');
- expect(content).toContain('export const TRIGGER_PATTERN');
- expect(content).toContain('export const CONTAINER_IMAGE');
- expect(content).toContain('export const DATA_DIR');
- expect(content).toContain('export const TIMEZONE');
-
- // Slack config added
- expect(content).toContain('export const SLACK_ONLY');
- });
-
- it('modified routing.test.ts includes Slack JID tests', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'routing.test.ts'),
- 'utf-8',
- );
-
- // Slack JID pattern tests
- expect(content).toContain('slack:C');
- expect(content).toContain('slack:D');
-
- // Mixed ordering test
- expect(content).toContain('mixes WhatsApp and Slack');
-
- // All original WhatsApp tests preserved
- expect(content).toContain('@g.us');
- expect(content).toContain('@s.whatsapp.net');
- expect(content).toContain('__group_sync__');
- });
-
it('slack.ts implements required Channel interface methods', () => {
const content = fs.readFileSync(
path.join(skillDir, 'add', 'src', 'channels', 'slack.ts'),
@@ -164,7 +94,6 @@ describe('slack skill package', () => {
// Key behaviors
expect(content).toContain('socketMode: true');
expect(content).toContain('MAX_MESSAGE_LENGTH');
- expect(content).toContain('thread_ts');
expect(content).toContain('TRIGGER_PATTERN');
expect(content).toContain('userNameCache');
});
diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md
index 8c941fa..484d851 100644
--- a/.claude/skills/add-telegram/SKILL.md
+++ b/.claude/skills/add-telegram/SKILL.md
@@ -17,10 +17,6 @@ Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, skip to Phase
Use `AskUserQuestion` to collect configuration:
-AskUserQuestion: Should Telegram replace WhatsApp or run alongside it?
-- **Replace WhatsApp** - Telegram will be the only channel (sets TELEGRAM_ONLY=true)
-- **Alongside** - Both Telegram and WhatsApp channels active
-
AskUserQuestion: Do you have a Telegram bot token, or do you need to create one?
If they have one, collect it now. If not, we'll create one in Phase 3.
@@ -46,18 +42,15 @@ npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
```
This deterministically:
-- Adds `src/channels/telegram.ts` (TelegramChannel class implementing Channel interface)
+- Adds `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`)
- Adds `src/channels/telegram.test.ts` (46 unit tests)
-- Three-way merges Telegram support into `src/index.ts` (multi-channel support, findChannel routing)
-- Three-way merges Telegram config into `src/config.ts` (TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY exports)
-- Three-way merges updated routing tests into `src/routing.test.ts`
+- Appends `import './telegram.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `grammy` npm dependency
-- Updates `.env.example` with `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY`
+- Updates `.env.example` with `TELEGRAM_BOT_TOKEN`
- Records the application in `.nanoclaw/state.yaml`
-If the apply reports merge conflicts, read the intent files:
-- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
-- `modify/src/config.ts.intent.md` — what changed for config.ts
+If the apply reports merge conflicts, read the intent file:
+- `modify/src/channels/index.ts.intent.md` — what changed and invariants
### Validate code changes
@@ -92,11 +85,7 @@ Add to `.env`:
TELEGRAM_BOT_TOKEN=
```
-If they chose to replace WhatsApp:
-
-```bash
-TELEGRAM_ONLY=true
-```
+Channels auto-enable when their credentials are present — no extra configuration needed.
Sync to container environment:
@@ -142,15 +131,16 @@ Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234
Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
-For a main chat (responds to all messages, uses the `main` folder):
+For a main chat (responds to all messages):
```typescript
registerGroup("tg:", {
name: "",
- folder: "main",
+ folder: "telegram_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
+ isMain: true,
});
```
@@ -159,7 +149,7 @@ For additional chats (trigger-only):
```typescript
registerGroup("tg:", {
name: "",
- folder: "",
+ folder: "telegram_",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
@@ -233,11 +223,9 @@ If they say yes, invoke the `/add-telegram-swarm` skill.
To remove Telegram integration:
-1. Delete `src/channels/telegram.ts`
-2. Remove `TelegramChannel` import and creation from `src/index.ts`
-3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
-4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
-5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
-6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
-7. Uninstall: `npm uninstall grammy`
-8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
+1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts`
+2. Remove `import './telegram.js'` from `src/channels/index.ts`
+3. Remove `TELEGRAM_BOT_TOKEN` from `.env`
+4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
+5. Uninstall: `npm uninstall grammy`
+6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
diff --git a/.claude/skills/add-telegram/add/src/channels/telegram.test.ts b/.claude/skills/add-telegram/add/src/channels/telegram.test.ts
index 950b607..9a97223 100644
--- a/.claude/skills/add-telegram/add/src/channels/telegram.test.ts
+++ b/.claude/skills/add-telegram/add/src/channels/telegram.test.ts
@@ -2,6 +2,12 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
+// Mock registry (registerChannel runs at import time)
+vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
+
+// Mock env reader (used by the factory, not needed in unit tests)
+vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
+
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
diff --git a/.claude/skills/add-telegram/add/src/channels/telegram.ts b/.claude/skills/add-telegram/add/src/channels/telegram.ts
index 43a6266..4176f03 100644
--- a/.claude/skills/add-telegram/add/src/channels/telegram.ts
+++ b/.claude/skills/add-telegram/add/src/channels/telegram.ts
@@ -1,7 +1,9 @@
import { Bot } from 'grammy';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
+import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
+import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
@@ -242,3 +244,14 @@ export class TelegramChannel implements Channel {
}
}
}
+
+registerChannel('telegram', (opts: ChannelOpts) => {
+ const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']);
+ const token =
+ process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || '';
+ if (!token) {
+ logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set');
+ return null;
+ }
+ return new TelegramChannel(token, opts);
+});
diff --git a/.claude/skills/add-telegram/manifest.yaml b/.claude/skills/add-telegram/manifest.yaml
index fe7a36a..ab279e0 100644
--- a/.claude/skills/add-telegram/manifest.yaml
+++ b/.claude/skills/add-telegram/manifest.yaml
@@ -6,15 +6,12 @@ adds:
- src/channels/telegram.ts
- src/channels/telegram.test.ts
modifies:
- - src/index.ts
- - src/config.ts
- - src/routing.test.ts
+ - src/channels/index.ts
structured:
npm_dependencies:
grammy: "^1.39.3"
env_additions:
- TELEGRAM_BOT_TOKEN
- - TELEGRAM_ONLY
conflicts: []
depends: []
test: "npx vitest run src/channels/telegram.test.ts"
diff --git a/.claude/skills/add-telegram/modify/src/channels/index.ts b/.claude/skills/add-telegram/modify/src/channels/index.ts
new file mode 100644
index 0000000..48356db
--- /dev/null
+++ b/.claude/skills/add-telegram/modify/src/channels/index.ts
@@ -0,0 +1,13 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+
+// slack
+
+// telegram
+import './telegram.js';
+
+// whatsapp
diff --git a/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md b/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md
new file mode 100644
index 0000000..1791175
--- /dev/null
+++ b/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md
@@ -0,0 +1,7 @@
+# Intent: Add Telegram channel import
+
+Add `import './telegram.js';` to the channel barrel file so the Telegram
+module self-registers with the channel registry on startup.
+
+This is an append-only change — existing import lines for other channels
+must be preserved.
diff --git a/.claude/skills/add-telegram/modify/src/config.ts b/.claude/skills/add-telegram/modify/src/config.ts
deleted file mode 100644
index f0093f2..0000000
--- a/.claude/skills/add-telegram/modify/src/config.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import os from 'os';
-import path from 'path';
-
-import { readEnvFile } from './env.js';
-
-// Read config values from .env (falls back to process.env).
-// Secrets are NOT read here — they stay on disk and are loaded only
-// where needed (container-runner.ts) to avoid leaking to child processes.
-const envConfig = readEnvFile([
- 'ASSISTANT_NAME',
- 'ASSISTANT_HAS_OWN_NUMBER',
- 'TELEGRAM_BOT_TOKEN',
- 'TELEGRAM_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 POLL_INTERVAL = 2000;
-export const SCHEDULER_POLL_INTERVAL = 60000;
-
-// Absolute paths needed for container mounts
-const PROJECT_ROOT = process.cwd();
-const HOME_DIR = process.env.HOME || os.homedir();
-
-// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
-export const MOUNT_ALLOWLIST_PATH = path.join(
- HOME_DIR,
- '.config',
- 'nanoclaw',
- 'mount-allowlist.json',
-);
-export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
-export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
-export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
-export const MAIN_GROUP_FOLDER = 'main';
-
-export const CONTAINER_IMAGE =
- process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
-export const CONTAINER_TIMEOUT = parseInt(
- process.env.CONTAINER_TIMEOUT || '1800000',
- 10,
-);
-export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
- process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
- 10,
-); // 10MB default
-export const IPC_POLL_INTERVAL = 1000;
-export const IDLE_TIMEOUT = parseInt(
- process.env.IDLE_TIMEOUT || '1800000',
- 10,
-); // 30min default — how long to keep container alive after last result
-export const MAX_CONCURRENT_CONTAINERS = Math.max(
- 1,
- parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
-);
-
-function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
-export const TRIGGER_PATTERN = new RegExp(
- `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
- 'i',
-);
-
-// Timezone for scheduled tasks (cron expressions, etc.)
-// Uses system timezone by default
-export const TIMEZONE =
- process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
-
-// Telegram configuration
-export const TELEGRAM_BOT_TOKEN =
- process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
-export const TELEGRAM_ONLY =
- (process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true';
diff --git a/.claude/skills/add-telegram/modify/src/config.ts.intent.md b/.claude/skills/add-telegram/modify/src/config.ts.intent.md
deleted file mode 100644
index 9db1692..0000000
--- a/.claude/skills/add-telegram/modify/src/config.ts.intent.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Intent: src/config.ts modifications
-
-## What changed
-Added two new configuration exports for Telegram channel support.
-
-## Key sections
-- **readEnvFile call**: Must include `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
-- **TELEGRAM_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
-- **TELEGRAM_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
-
-## Invariants
-- All existing config exports remain unchanged
-- New Telegram keys are added to the `readEnvFile` call alongside existing keys
-- New exports are appended at the end of the file
-- No existing behavior is modified — Telegram config is additive only
-- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
-
-## Must-keep
-- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
-- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
-- The `escapeRegex` helper and `TRIGGER_PATTERN` construction
diff --git a/.claude/skills/add-telegram/modify/src/index.ts b/.claude/skills/add-telegram/modify/src/index.ts
deleted file mode 100644
index b91e244..0000000
--- a/.claude/skills/add-telegram/modify/src/index.ts
+++ /dev/null
@@ -1,509 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-import {
- ASSISTANT_NAME,
- IDLE_TIMEOUT,
- MAIN_GROUP_FOLDER,
- POLL_INTERVAL,
- TELEGRAM_BOT_TOKEN,
- TELEGRAM_ONLY,
- TRIGGER_PATTERN,
-} from './config.js';
-import { TelegramChannel } from './channels/telegram.js';
-import { WhatsAppChannel } from './channels/whatsapp.js';
-import {
- ContainerOutput,
- runContainerAgent,
- writeGroupsSnapshot,
- writeTasksSnapshot,
-} from './container-runner.js';
-import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
-import {
- getAllChats,
- getAllRegisteredGroups,
- getAllSessions,
- getAllTasks,
- getMessagesSince,
- getNewMessages,
- getRouterState,
- initDatabase,
- setRegisteredGroup,
- setRouterState,
- setSession,
- storeChatMetadata,
- storeMessage,
-} from './db.js';
-import { GroupQueue } from './group-queue.js';
-import { resolveGroupFolderPath } from './group-folder.js';
-import { startIpcWatcher } from './ipc.js';
-import { findChannel, formatMessages, formatOutbound } from './router.js';
-import { startSchedulerLoop } from './task-scheduler.js';
-import { Channel, NewMessage, RegisteredGroup } from './types.js';
-import { logger } from './logger.js';
-
-// Re-export for backwards compatibility during refactor
-export { escapeXml, formatMessages } from './router.js';
-
-let lastTimestamp = '';
-let sessions: Record = {};
-let registeredGroups: Record = {};
-let lastAgentTimestamp: Record = {};
-let messageLoopRunning = false;
-
-let whatsapp: WhatsAppChannel;
-const channels: Channel[] = [];
-const queue = new GroupQueue();
-
-function loadState(): void {
- lastTimestamp = getRouterState('last_timestamp') || '';
- const agentTs = getRouterState('last_agent_timestamp');
- try {
- lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
- } catch {
- logger.warn('Corrupted last_agent_timestamp in DB, resetting');
- lastAgentTimestamp = {};
- }
- sessions = getAllSessions();
- registeredGroups = getAllRegisteredGroups();
- logger.info(
- { groupCount: Object.keys(registeredGroups).length },
- 'State loaded',
- );
-}
-
-function saveState(): void {
- setRouterState('last_timestamp', lastTimestamp);
- setRouterState(
- 'last_agent_timestamp',
- JSON.stringify(lastAgentTimestamp),
- );
-}
-
-function registerGroup(jid: string, group: RegisteredGroup): void {
- let groupDir: string;
- try {
- groupDir = resolveGroupFolderPath(group.folder);
- } catch (err) {
- logger.warn(
- { jid, folder: group.folder, err },
- 'Rejecting group registration with invalid folder',
- );
- return;
- }
-
- registeredGroups[jid] = group;
- setRegisteredGroup(jid, group);
-
- // Create group folder
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
-
- logger.info(
- { jid, name: group.name, folder: group.folder },
- 'Group registered',
- );
-}
-
-/**
- * Get available groups list for the agent.
- * Returns groups ordered by most recent activity.
- */
-export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
- const chats = getAllChats();
- const registeredJids = new Set(Object.keys(registeredGroups));
-
- return chats
- .filter((c) => c.jid !== '__group_sync__' && c.is_group)
- .map((c) => ({
- jid: c.jid,
- name: c.name,
- lastActivity: c.last_message_time,
- isRegistered: registeredJids.has(c.jid),
- }));
-}
-
-/** @internal - exported for testing */
-export function _setRegisteredGroups(groups: Record): void {
- registeredGroups = groups;
-}
-
-/**
- * Process all pending messages for a group.
- * Called by the GroupQueue when it's this group's turn.
- */
-async function processGroupMessages(chatJid: string): Promise {
- const group = registeredGroups[chatJid];
- if (!group) return true;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- return true;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
-
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
-
- if (missedMessages.length === 0) return true;
-
- // For non-main groups, check if trigger is required and present
- if (!isMainGroup && group.requiresTrigger !== false) {
- const hasTrigger = missedMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) return true;
- }
-
- const prompt = formatMessages(missedMessages);
-
- // Advance cursor so the piping path in startMessageLoop won't re-fetch
- // these messages. Save the old cursor so we can roll back on error.
- const previousCursor = lastAgentTimestamp[chatJid] || '';
- lastAgentTimestamp[chatJid] =
- missedMessages[missedMessages.length - 1].timestamp;
- saveState();
-
- logger.info(
- { group: group.name, messageCount: missedMessages.length },
- 'Processing messages',
- );
-
- // Track idle timer for closing stdin when agent is idle
- let idleTimer: ReturnType | null = null;
-
- const resetIdleTimer = () => {
- if (idleTimer) clearTimeout(idleTimer);
- idleTimer = setTimeout(() => {
- logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
- queue.closeStdin(chatJid);
- }, IDLE_TIMEOUT);
- };
-
- await channel.setTyping?.(chatJid, true);
- let hadError = false;
- let outputSentToUser = false;
-
- const output = await runAgent(group, prompt, chatJid, async (result) => {
- // Streaming output callback — called for each agent result
- if (result.result) {
- const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
- // Strip ... blocks — agent uses these for internal reasoning
- const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
- logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
- if (text) {
- await channel.sendMessage(chatJid, text);
- outputSentToUser = true;
- }
- // Only reset idle timer on actual results, not session-update markers (result: null)
- resetIdleTimer();
- }
-
- if (result.status === 'success') {
- queue.notifyIdle(chatJid);
- }
-
- if (result.status === 'error') {
- hadError = true;
- }
- });
-
- await channel.setTyping?.(chatJid, false);
- if (idleTimer) clearTimeout(idleTimer);
-
- if (output === 'error' || hadError) {
- // If we already sent output to the user, don't roll back the cursor —
- // the user got their response and re-processing would send duplicates.
- if (outputSentToUser) {
- logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
- return true;
- }
- // Roll back cursor so retries can re-process these messages
- lastAgentTimestamp[chatJid] = previousCursor;
- saveState();
- logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
- return false;
- }
-
- return true;
-}
-
-async function runAgent(
- group: RegisteredGroup,
- prompt: string,
- chatJid: string,
- onOutput?: (output: ContainerOutput) => Promise,
-): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
- const sessionId = sessions[group.folder];
-
- // Update tasks snapshot for container to read (filtered by group)
- const tasks = getAllTasks();
- writeTasksSnapshot(
- group.folder,
- isMain,
- tasks.map((t) => ({
- id: t.id,
- groupFolder: t.group_folder,
- prompt: t.prompt,
- schedule_type: t.schedule_type,
- schedule_value: t.schedule_value,
- status: t.status,
- next_run: t.next_run,
- })),
- );
-
- // Update available groups snapshot (main group only can see all groups)
- const availableGroups = getAvailableGroups();
- writeGroupsSnapshot(
- group.folder,
- isMain,
- availableGroups,
- new Set(Object.keys(registeredGroups)),
- );
-
- // Wrap onOutput to track session ID from streamed results
- const wrappedOnOutput = onOutput
- ? async (output: ContainerOutput) => {
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
- await onOutput(output);
- }
- : undefined;
-
- try {
- const output = await runContainerAgent(
- group,
- {
- prompt,
- sessionId,
- groupFolder: group.folder,
- chatJid,
- isMain,
- assistantName: ASSISTANT_NAME,
- },
- (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
- wrappedOnOutput,
- );
-
- if (output.newSessionId) {
- sessions[group.folder] = output.newSessionId;
- setSession(group.folder, output.newSessionId);
- }
-
- if (output.status === 'error') {
- logger.error(
- { group: group.name, error: output.error },
- 'Container agent error',
- );
- return 'error';
- }
-
- return 'success';
- } catch (err) {
- logger.error({ group: group.name, err }, 'Agent error');
- return 'error';
- }
-}
-
-async function startMessageLoop(): Promise {
- if (messageLoopRunning) {
- logger.debug('Message loop already running, skipping duplicate start');
- return;
- }
- messageLoopRunning = true;
-
- logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
-
- while (true) {
- try {
- const jids = Object.keys(registeredGroups);
- const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
-
- if (messages.length > 0) {
- logger.info({ count: messages.length }, 'New messages');
-
- // Advance the "seen" cursor for all messages immediately
- lastTimestamp = newTimestamp;
- saveState();
-
- // Deduplicate by group
- const messagesByGroup = new Map();
- for (const msg of messages) {
- const existing = messagesByGroup.get(msg.chat_jid);
- if (existing) {
- existing.push(msg);
- } else {
- messagesByGroup.set(msg.chat_jid, [msg]);
- }
- }
-
- for (const [chatJid, groupMessages] of messagesByGroup) {
- const group = registeredGroups[chatJid];
- if (!group) continue;
-
- const channel = findChannel(channels, chatJid);
- if (!channel) {
- console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
- continue;
- }
-
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
- const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
-
- // For non-main groups, only act on trigger messages.
- // Non-trigger messages accumulate in DB and get pulled as
- // context when a trigger eventually arrives.
- if (needsTrigger) {
- const hasTrigger = groupMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
- );
- if (!hasTrigger) continue;
- }
-
- // Pull all messages since lastAgentTimestamp so non-trigger
- // context that accumulated between triggers is included.
- const allPending = getMessagesSince(
- chatJid,
- lastAgentTimestamp[chatJid] || '',
- ASSISTANT_NAME,
- );
- const messagesToSend =
- allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
-
- if (queue.sendMessage(chatJid, formatted)) {
- logger.debug(
- { chatJid, count: messagesToSend.length },
- 'Piped messages to active container',
- );
- lastAgentTimestamp[chatJid] =
- messagesToSend[messagesToSend.length - 1].timestamp;
- saveState();
- // Show typing indicator while the container processes the piped message
- channel.setTyping?.(chatJid, true)?.catch((err) =>
- logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
- );
- } else {
- // No active container — enqueue for a new one
- queue.enqueueMessageCheck(chatJid);
- }
- }
- }
- } catch (err) {
- logger.error({ err }, 'Error in message loop');
- }
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
- }
-}
-
-/**
- * Startup recovery: check for unprocessed messages in registered groups.
- * Handles crash between advancing lastTimestamp and processing messages.
- */
-function recoverPendingMessages(): void {
- for (const [chatJid, group] of Object.entries(registeredGroups)) {
- const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
- const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
- if (pending.length > 0) {
- logger.info(
- { group: group.name, pendingCount: pending.length },
- 'Recovery: found unprocessed messages',
- );
- queue.enqueueMessageCheck(chatJid);
- }
- }
-}
-
-function ensureContainerSystemRunning(): void {
- ensureContainerRuntimeRunning();
- cleanupOrphans();
-}
-
-async function main(): Promise {
- ensureContainerSystemRunning();
- initDatabase();
- logger.info('Database initialized');
- loadState();
-
- // Graceful shutdown handlers
- const shutdown = async (signal: string) => {
- logger.info({ signal }, 'Shutdown signal received');
- await queue.shutdown(10000);
- for (const ch of channels) await ch.disconnect();
- process.exit(0);
- };
- process.on('SIGTERM', () => shutdown('SIGTERM'));
- process.on('SIGINT', () => shutdown('SIGINT'));
-
- // Channel callbacks (shared by all channels)
- const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
- onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
- storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
- registeredGroups: () => registeredGroups,
- };
-
- // Create and connect channels
- if (TELEGRAM_BOT_TOKEN) {
- const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
- channels.push(telegram);
- await telegram.connect();
- }
-
- if (!TELEGRAM_ONLY) {
- whatsapp = new WhatsAppChannel(channelOpts);
- channels.push(whatsapp);
- await whatsapp.connect();
- }
-
- // Start subsystems (independently of connection handler)
- startSchedulerLoop({
- registeredGroups: () => registeredGroups,
- getSessions: () => sessions,
- queue,
- 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;
- }
- const text = formatOutbound(rawText);
- if (text) await channel.sendMessage(jid, text);
- },
- });
- startIpcWatcher({
- sendMessage: (jid, text) => {
- const channel = findChannel(channels, jid);
- if (!channel) throw new Error(`No channel for JID: ${jid}`);
- return channel.sendMessage(jid, text);
- },
- registeredGroups: () => registeredGroups,
- registerGroup,
- syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
- getAvailableGroups,
- writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
- });
- queue.setProcessMessagesFn(processGroupMessages);
- recoverPendingMessages();
- startMessageLoop().catch((err) => {
- logger.fatal({ err }, 'Message loop crashed unexpectedly');
- process.exit(1);
- });
-}
-
-// Guard: only run when executed directly, not when imported by tests
-const isDirectRun =
- process.argv[1] &&
- new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
-
-if (isDirectRun) {
- main().catch((err) => {
- logger.error({ err }, 'Failed to start NanoClaw');
- process.exit(1);
- });
-}
diff --git a/.claude/skills/add-telegram/modify/src/index.ts.intent.md b/.claude/skills/add-telegram/modify/src/index.ts.intent.md
deleted file mode 100644
index 1053490..0000000
--- a/.claude/skills/add-telegram/modify/src/index.ts.intent.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Intent: src/index.ts modifications
-
-## What changed
-Refactored from single WhatsApp channel to multi-channel architecture using the `Channel` interface.
-
-## Key sections
-
-### Imports (top of file)
-- Added: `TelegramChannel` from `./channels/telegram.js`
-- Added: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY` from `./config.js`
-- Added: `findChannel` from `./router.js`
-- Added: `Channel` type from `./types.js`
-
-### Module-level state
-- Added: `const channels: Channel[] = []` — array of all active channels
-- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference
-
-### processGroupMessages()
-- Added: `findChannel(channels, chatJid)` lookup at the start
-- Changed: `whatsapp.setTyping()` → `channel.setTyping?.()` (optional chaining)
-- Changed: `whatsapp.sendMessage()` → `channel.sendMessage()` in output callback
-
-### getAvailableGroups()
-- Unchanged: uses `c.is_group` filter from base (Telegram channels pass `isGroup=true` via `onChatMetadata`)
-
-### startMessageLoop()
-- Added: `findChannel(channels, chatJid)` lookup per group in message processing
-- Changed: `whatsapp.setTyping()` → `channel.setTyping?.()` for typing indicators
-
-### main()
-- Changed: shutdown disconnects all channels via `for (const ch of channels)`
-- Added: shared `channelOpts` object for channel callbacks
-- Added: conditional WhatsApp creation (`if (!TELEGRAM_ONLY)`)
-- Added: conditional Telegram creation (`if (TELEGRAM_BOT_TOKEN)`)
-- Changed: scheduler `sendMessage` uses `findChannel()` → `channel.sendMessage()`
-- Changed: IPC `sendMessage` uses `findChannel()` → `channel.sendMessage()`
-
-## Invariants
-- All existing message processing logic (triggers, cursors, idle timers) is preserved
-- The `runAgent` function is completely unchanged
-- State management (loadState/saveState) is unchanged
-- Recovery logic is unchanged
-- Container runtime check is unchanged (ensureContainerSystemRunning)
-
-## Must-keep
-- The `escapeXml` and `formatMessages` re-exports
-- The `_setRegisteredGroups` test helper
-- The `isDirectRun` guard at bottom
-- All error handling and cursor rollback logic in processGroupMessages
-- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)
diff --git a/.claude/skills/add-telegram/modify/src/routing.test.ts b/.claude/skills/add-telegram/modify/src/routing.test.ts
deleted file mode 100644
index 5b44063..0000000
--- a/.claude/skills/add-telegram/modify/src/routing.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-
-import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
-import { getAvailableGroups, _setRegisteredGroups } from './index.js';
-
-beforeEach(() => {
- _initTestDatabase();
- _setRegisteredGroups({});
-});
-
-// --- JID ownership patterns ---
-
-describe('JID ownership patterns', () => {
- // These test the patterns that will become ownsJid() on the Channel interface
-
- it('WhatsApp group JID: ends with @g.us', () => {
- const jid = '12345678@g.us';
- expect(jid.endsWith('@g.us')).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);
- });
-
- it('Telegram JID: starts with tg:', () => {
- const jid = 'tg:123456789';
- expect(jid.startsWith('tg:')).toBe(true);
- });
-
- it('Telegram group JID: starts with tg: and has negative ID', () => {
- const jid = 'tg:-1001234567890';
- expect(jid.startsWith('tg:')).toBe(true);
- });
-});
-
-// --- getAvailableGroups ---
-
-describe('getAvailableGroups', () => {
- it('returns only groups, excludes DMs', () => {
- storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
- storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
- storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.jid)).toContain('group1@g.us');
- expect(groups.map((g) => g.jid)).toContain('group2@g.us');
- expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
- });
-
- it('excludes __group_sync__ sentinel', () => {
- storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
- storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('marks registered groups correctly', () => {
- storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
- storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
-
- _setRegisteredGroups({
- 'reg@g.us': {
- name: 'Registered',
- folder: 'registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const reg = groups.find((g) => g.jid === 'reg@g.us');
- const unreg = groups.find((g) => g.jid === 'unreg@g.us');
-
- expect(reg?.isRegistered).toBe(true);
- expect(unreg?.isRegistered).toBe(false);
- });
-
- it('returns groups ordered by most recent activity', () => {
- storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
- storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
- storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups[0].jid).toBe('new@g.us');
- expect(groups[1].jid).toBe('mid@g.us');
- expect(groups[2].jid).toBe('old@g.us');
- });
-
- 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');
- // Explicitly non-group with unusual JID
- storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
- // A real group for contrast
- storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('group@g.us');
- });
-
- it('returns empty array when no chats exist', () => {
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(0);
- });
-
- it('includes Telegram chat JIDs', () => {
- storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'Telegram Chat', 'telegram', true);
- storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('tg:100200300');
- });
-
- it('returns Telegram group JIDs with negative IDs', () => {
- storeChatMetadata('tg:-1001234567890', '2024-01-01T00:00:01.000Z', 'TG Group', 'telegram', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(1);
- expect(groups[0].jid).toBe('tg:-1001234567890');
- expect(groups[0].name).toBe('TG Group');
- });
-
- it('marks registered Telegram chats correctly', () => {
- storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'TG Registered', 'telegram', true);
- storeChatMetadata('tg:999999', '2024-01-01T00:00:02.000Z', 'TG Unregistered', 'telegram', true);
-
- _setRegisteredGroups({
- 'tg:100200300': {
- name: 'TG Registered',
- folder: 'tg-registered',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- });
-
- const groups = getAvailableGroups();
- const tgReg = groups.find((g) => g.jid === 'tg:100200300');
- const tgUnreg = groups.find((g) => g.jid === 'tg:999999');
-
- expect(tgReg?.isRegistered).toBe(true);
- expect(tgUnreg?.isRegistered).toBe(false);
- });
-
- it('mixes WhatsApp and Telegram chats ordered by activity', () => {
- storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
- storeChatMetadata('tg:100', '2024-01-01T00:00:03.000Z', 'Telegram', 'telegram', true);
- storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
-
- const groups = getAvailableGroups();
- expect(groups).toHaveLength(3);
- expect(groups[0].jid).toBe('tg:100');
- expect(groups[1].jid).toBe('wa2@g.us');
- expect(groups[2].jid).toBe('wa@g.us');
- });
-});
diff --git a/.claude/skills/add-telegram/tests/telegram.test.ts b/.claude/skills/add-telegram/tests/telegram.test.ts
index 50dd599..882986a 100644
--- a/.claude/skills/add-telegram/tests/telegram.test.ts
+++ b/.claude/skills/add-telegram/tests/telegram.test.ts
@@ -16,15 +16,28 @@ describe('telegram skill package', () => {
});
it('has all files declared in adds', () => {
- const addFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.ts');
- expect(fs.existsSync(addFile)).toBe(true);
+ const channelFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'telegram.ts',
+ );
+ expect(fs.existsSync(channelFile)).toBe(true);
- const content = fs.readFileSync(addFile, 'utf-8');
+ const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class TelegramChannel');
expect(content).toContain('implements Channel');
+ expect(content).toContain("registerChannel('telegram'");
// Test file for the channel
- const testFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.test.ts');
+ const testFile = path.join(
+ skillDir,
+ 'add',
+ 'src',
+ 'channels',
+ 'telegram.test.ts',
+ );
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
@@ -32,87 +45,25 @@ describe('telegram skill package', () => {
});
it('has all files declared in modifies', () => {
- const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
- const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
- const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
-
+ // Channel barrel file
+ const indexFile = path.join(
+ skillDir,
+ 'modify',
+ 'src',
+ 'channels',
+ 'index.ts',
+ );
expect(fs.existsSync(indexFile)).toBe(true);
- expect(fs.existsSync(configFile)).toBe(true);
- expect(fs.existsSync(routingTestFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
- expect(indexContent).toContain('TelegramChannel');
- expect(indexContent).toContain('TELEGRAM_BOT_TOKEN');
- expect(indexContent).toContain('TELEGRAM_ONLY');
- expect(indexContent).toContain('findChannel');
- expect(indexContent).toContain('channels: Channel[]');
-
- const configContent = fs.readFileSync(configFile, 'utf-8');
- expect(configContent).toContain('TELEGRAM_BOT_TOKEN');
- expect(configContent).toContain('TELEGRAM_ONLY');
+ expect(indexContent).toContain("import './telegram.js'");
});
it('has intent files for modified files', () => {
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
- expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
- });
-
- it('modified index.ts preserves core structure', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Core functions still present
- expect(content).toContain('function loadState()');
- expect(content).toContain('function saveState()');
- expect(content).toContain('function registerGroup(');
- expect(content).toContain('function getAvailableGroups()');
- expect(content).toContain('function processGroupMessages(');
- expect(content).toContain('function runAgent(');
- expect(content).toContain('function startMessageLoop()');
- expect(content).toContain('function recoverPendingMessages()');
- expect(content).toContain('function ensureContainerSystemRunning()');
- expect(content).toContain('async function main()');
-
- // Test helper preserved
- expect(content).toContain('_setRegisteredGroups');
-
- // Direct-run guard preserved
- expect(content).toContain('isDirectRun');
- });
-
- it('modified index.ts includes Telegram channel creation', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'index.ts'),
- 'utf-8',
- );
-
- // Multi-channel architecture
- expect(content).toContain('const channels: Channel[] = []');
- expect(content).toContain('channels.push(whatsapp)');
- expect(content).toContain('channels.push(telegram)');
-
- // Conditional channel creation
- expect(content).toContain('if (!TELEGRAM_ONLY)');
- expect(content).toContain('if (TELEGRAM_BOT_TOKEN)');
-
- // Shutdown disconnects all channels
- expect(content).toContain('for (const ch of channels) await ch.disconnect()');
- });
-
- it('modified config.ts preserves all existing exports', () => {
- const content = fs.readFileSync(
- path.join(skillDir, 'modify', 'src', 'config.ts'),
- 'utf-8',
- );
-
- // All original exports preserved
- expect(content).toContain('export const ASSISTANT_NAME');
- expect(content).toContain('export const POLL_INTERVAL');
- expect(content).toContain('export const TRIGGER_PATTERN');
- expect(content).toContain('export const CONTAINER_IMAGE');
- expect(content).toContain('export const DATA_DIR');
- expect(content).toContain('export const TIMEZONE');
+ expect(
+ fs.existsSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
+ ),
+ ).toBe(true);
});
});
diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md
new file mode 100644
index 0000000..f572ea0
--- /dev/null
+++ b/.claude/skills/add-whatsapp/SKILL.md
@@ -0,0 +1,345 @@
+---
+name: add-whatsapp
+description: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them. Uses QR code or pairing code for authentication.
+---
+
+# Add WhatsApp Channel
+
+This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration.
+
+## Phase 1: Pre-flight
+
+### Check current state
+
+Check if WhatsApp is already configured. If `store/auth/` exists with credential files, skip to Phase 4 (Registration) or Phase 5 (Verify).
+
+```bash
+ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
+```
+
+### Detect environment
+
+Check whether the environment is headless (no display server):
+
+```bash
+[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
+```
+
+### Ask the user
+
+Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**
+
+If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
+- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
+- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
+
+Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
+- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code
+- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
+- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
+
+If they chose pairing code:
+
+AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890)
+
+## Phase 2: Verify Code
+
+Apply the skill to install the WhatsApp channel code and dependencies:
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
+```
+
+Verify the code was placed correctly:
+
+```bash
+test -f src/channels/whatsapp.ts && echo "WhatsApp channel code present" || echo "ERROR: WhatsApp channel code missing — re-run skill apply"
+```
+
+### Verify dependencies
+
+```bash
+node -e "require('@whiskeysockets/baileys')" 2>/dev/null && echo "Baileys installed" || echo "Installing Baileys..."
+```
+
+If not installed:
+
+```bash
+npm install @whiskeysockets/baileys qrcode qrcode-terminal
+```
+
+### Validate build
+
+```bash
+npm run build
+```
+
+Build must be clean before proceeding.
+
+## Phase 3: Authentication
+
+### Clean previous auth state (if re-authenticating)
+
+```bash
+rm -rf store/auth/
+```
+
+### Run WhatsApp authentication
+
+For QR code in browser (recommended):
+
+```bash
+npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
+```
+
+(Bash timeout: 150000ms)
+
+Tell the user:
+
+> A browser window will open with a QR code.
+>
+> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
+> 2. Scan the QR code in the browser
+> 3. The page will show "Authenticated!" when done
+
+For QR code in terminal:
+
+```bash
+npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal
+```
+
+Tell the user to run `npm run auth` in another terminal, then:
+
+> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
+> 2. Scan the QR code displayed in the terminal
+
+For pairing code:
+
+```bash
+npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone
+```
+
+(Bash timeout: 150000ms). Display PAIRING_CODE from output.
+
+Tell the user:
+
+> A pairing code will appear. **Enter it within 60 seconds** — codes expire quickly.
+>
+> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
+> 2. Tap **Link with phone number instead**
+> 3. Enter the code immediately
+>
+> If the code expires, re-run the command — a new code will be generated.
+
+**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
+
+### Verify authentication succeeded
+
+```bash
+test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
+```
+
+### Configure environment
+
+Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists.
+
+Sync to container environment:
+
+```bash
+mkdir -p data/env && cp .env data/env/env
+```
+
+## Phase 4: Registration
+
+### Configure trigger and channel type
+
+Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
+
+AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)?
+- **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group)
+- **Dedicated number** - A separate phone/SIM for the assistant
+
+AskUserQuestion: What trigger word should activate the assistant?
+- **@Andy** - Default trigger
+- **@Claw** - Short and easy
+- **@Claude** - Match the AI name
+
+AskUserQuestion: What should the assistant call itself?
+- **Andy** - Default name
+- **Claw** - Short and easy
+- **Claude** - Match the AI name
+
+AskUserQuestion: Where do you want to chat with the assistant?
+
+**Shared number options:**
+- **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation
+- **Solo group** - A group with just you and the linked device
+- **Existing group** - An existing WhatsApp group
+
+**Dedicated number options:**
+- **DM with bot** (Recommended) - Direct message the bot's number
+- **Solo group** - A group with just you and the bot
+- **Existing group** - An existing WhatsApp group
+
+### Get the JID
+
+**Self-chat:** JID = your phone number with `@s.whatsapp.net`. Extract from auth credentials:
+
+```bash
+node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"
+```
+
+**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net`
+
+**Group (solo, existing):** Run group sync and list available groups:
+
+```bash
+npx tsx setup/index.ts --step groups
+npx tsx setup/index.ts --step groups --list
+```
+
+The output shows `JID|GroupName` pairs. Present candidates as AskUserQuestion (names only, not JIDs).
+
+### Register the chat
+
+```bash
+npx tsx setup/index.ts --step register \
+ --jid "" \
+ --name "" \
+ --trigger "@" \
+ --folder "whatsapp_main" \
+ --channel whatsapp \
+ --assistant-name "" \
+ --is-main \
+ --no-trigger-required # Only for main/self-chat
+```
+
+For additional groups (trigger-required):
+
+```bash
+npx tsx setup/index.ts --step register \
+ --jid "" \
+ --name "" \
+ --trigger "@" \
+ --folder "whatsapp_" \
+ --channel whatsapp
+```
+
+## Phase 5: Verify
+
+### Build and restart
+
+```bash
+npm run build
+```
+
+Restart the service:
+
+```bash
+# macOS (launchd)
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+
+# Linux (systemd)
+systemctl --user restart nanoclaw
+
+# Linux (nohup fallback)
+bash start-nanoclaw.sh
+```
+
+### Test the connection
+
+Tell the user:
+
+> Send a message to your registered WhatsApp chat:
+> - For self-chat / main: Any message works
+> - For groups: Use the trigger word (e.g., "@Andy hello")
+>
+> The assistant should respond within a few seconds.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log
+```
+
+## Troubleshooting
+
+### QR code expired
+
+QR codes expire after ~60 seconds. Re-run the auth command:
+
+```bash
+rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts
+```
+
+### Pairing code not working
+
+Codes expire in ~60 seconds. To retry:
+
+```bash
+rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone
+```
+
+Enter the code **immediately** when it appears. Also ensure:
+1. Phone number includes country code without `+` (e.g., `1234567890`)
+2. Phone has internet access
+3. WhatsApp is updated to the latest version
+
+If pairing code keeps failing, switch to QR-browser auth instead:
+
+```bash
+rm -rf store/auth/ && npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
+```
+
+### "conflict" disconnection
+
+This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running:
+
+```bash
+pkill -f "node dist/index.js"
+# Then restart
+```
+
+### Bot not responding
+
+Check:
+1. Auth credentials exist: `ls store/auth/creds.json`
+3. Chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE '%whatsapp%' OR jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"`
+4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
+5. Logs: `tail -50 logs/nanoclaw.log`
+
+### Group names not showing
+
+Run group metadata sync:
+
+```bash
+npx tsx setup/index.ts --step groups
+```
+
+This fetches all group names from WhatsApp. Runs automatically every 24 hours.
+
+## After Setup
+
+If running `npm run dev` while the service is active:
+
+```bash
+# macOS:
+launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
+npm run dev
+# When done testing:
+launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
+
+# Linux:
+# systemctl --user stop nanoclaw
+# npm run dev
+# systemctl --user start nanoclaw
+```
+
+## Removal
+
+To remove WhatsApp integration:
+
+1. Delete auth credentials: `rm -rf store/auth/`
+2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"`
+3. Sync env: `mkdir -p data/env && cp .env data/env/env`
+4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
diff --git a/setup/whatsapp-auth.ts b/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
similarity index 98%
rename from setup/whatsapp-auth.ts
rename to .claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
index feed41b..4aa3433 100644
--- a/setup/whatsapp-auth.ts
+++ b/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
@@ -1,6 +1,5 @@
/**
- * Step: whatsapp-auth — Full WhatsApp auth flow with polling.
- * Replaces 04-auth-whatsapp.sh
+ * Step: whatsapp-auth — WhatsApp interactive auth (QR code / pairing code).
*/
import { execSync, spawn } from 'child_process';
import fs from 'fs';
@@ -125,6 +124,7 @@ function emitAuthStatus(
export async function run(args: string[]): Promise {
const projectRoot = process.cwd();
+
const { method, phone } = parseArgs(args);
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
@@ -157,7 +157,7 @@ export async function run(args: string[]): Promise {
}
// Clean stale state
- logger.info({ method }, 'Starting WhatsApp auth');
+ logger.info({ method }, 'Starting channel authentication');
try {
fs.rmSync(path.join(projectRoot, 'store', 'auth'), {
recursive: true,
diff --git a/src/channels/whatsapp.test.ts b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts
similarity index 100%
rename from src/channels/whatsapp.test.ts
rename to .claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts
diff --git a/src/channels/whatsapp.ts b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
similarity index 98%
rename from src/channels/whatsapp.ts
rename to .claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
index d8b4e1e..64fcf57 100644
--- a/src/channels/whatsapp.ts
+++ b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
@@ -25,6 +25,7 @@ import {
OnChatMetadata,
RegisteredGroup,
} from '../types.js';
+import { registerChannel, ChannelOpts } from './registry.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -291,6 +292,10 @@ export class WhatsAppChannel implements Channel {
}
}
+ async syncGroups(force: boolean): Promise {
+ return this.syncGroupMetadata(force);
+ }
+
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
@@ -382,3 +387,5 @@ export class WhatsAppChannel implements Channel {
}
}
}
+
+registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts));
diff --git a/src/whatsapp-auth.ts b/.claude/skills/add-whatsapp/add/src/whatsapp-auth.ts
similarity index 100%
rename from src/whatsapp-auth.ts
rename to .claude/skills/add-whatsapp/add/src/whatsapp-auth.ts
diff --git a/.claude/skills/add-whatsapp/manifest.yaml b/.claude/skills/add-whatsapp/manifest.yaml
new file mode 100644
index 0000000..de1a4cc
--- /dev/null
+++ b/.claude/skills/add-whatsapp/manifest.yaml
@@ -0,0 +1,23 @@
+skill: whatsapp
+version: 1.0.0
+description: "WhatsApp channel via Baileys (Multi-Device Web API)"
+core_version: 0.1.0
+adds:
+ - src/channels/whatsapp.ts
+ - src/channels/whatsapp.test.ts
+ - src/whatsapp-auth.ts
+ - setup/whatsapp-auth.ts
+modifies:
+ - src/channels/index.ts
+ - setup/index.ts
+structured:
+ npm_dependencies:
+ "@whiskeysockets/baileys": "^7.0.0-rc.9"
+ "qrcode": "^1.5.4"
+ "qrcode-terminal": "^0.12.0"
+ "@types/qrcode-terminal": "^0.12.0"
+ env_additions:
+ - ASSISTANT_HAS_OWN_NUMBER
+conflicts: []
+depends: []
+test: "npx vitest run src/channels/whatsapp.test.ts"
diff --git a/.claude/skills/add-whatsapp/modify/setup/index.ts b/.claude/skills/add-whatsapp/modify/setup/index.ts
new file mode 100644
index 0000000..d962923
--- /dev/null
+++ b/.claude/skills/add-whatsapp/modify/setup/index.ts
@@ -0,0 +1,60 @@
+/**
+ * Setup CLI entry point.
+ * Usage: npx tsx setup/index.ts --step [args...]
+ */
+import { logger } from '../src/logger.js';
+import { emitStatus } from './status.js';
+
+const STEPS: Record<
+ string,
+ () => Promise<{ run: (args: string[]) => Promise }>
+> = {
+ environment: () => import('./environment.js'),
+ channels: () => import('./channels.js'),
+ container: () => import('./container.js'),
+ 'whatsapp-auth': () => import('./whatsapp-auth.js'),
+ groups: () => import('./groups.js'),
+ register: () => import('./register.js'),
+ mounts: () => import('./mounts.js'),
+ service: () => import('./service.js'),
+ verify: () => import('./verify.js'),
+};
+
+async function main(): Promise {
+ const args = process.argv.slice(2);
+ const stepIdx = args.indexOf('--step');
+
+ if (stepIdx === -1 || !args[stepIdx + 1]) {
+ console.error(
+ `Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`,
+ );
+ process.exit(1);
+ }
+
+ const stepName = args[stepIdx + 1];
+ const stepArgs = args.filter(
+ (a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--',
+ );
+
+ const loader = STEPS[stepName];
+ if (!loader) {
+ console.error(`Unknown step: ${stepName}`);
+ console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
+ process.exit(1);
+ }
+
+ try {
+ const mod = await loader();
+ await mod.run(stepArgs);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error({ err, step: stepName }, 'Setup step failed');
+ emitStatus(stepName.toUpperCase(), {
+ STATUS: 'failed',
+ ERROR: message,
+ });
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md b/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md
new file mode 100644
index 0000000..0a5feef
--- /dev/null
+++ b/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md
@@ -0,0 +1 @@
+Add `'whatsapp-auth': () => import('./whatsapp-auth.js'),` to the setup STEPS map so the WhatsApp authentication step is available during setup.
diff --git a/.claude/skills/add-whatsapp/modify/src/channels/index.ts b/.claude/skills/add-whatsapp/modify/src/channels/index.ts
new file mode 100644
index 0000000..0d15ba3
--- /dev/null
+++ b/.claude/skills/add-whatsapp/modify/src/channels/index.ts
@@ -0,0 +1,13 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+
+// slack
+
+// telegram
+
+// whatsapp
+import './whatsapp.js';
diff --git a/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md b/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md
new file mode 100644
index 0000000..d4eea71
--- /dev/null
+++ b/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md
@@ -0,0 +1,7 @@
+# Intent: Add WhatsApp channel import
+
+Add `import './whatsapp.js';` to the channel barrel file so the WhatsApp
+module self-registers with the channel registry on startup.
+
+This is an append-only change — existing import lines for other channels
+must be preserved.
diff --git a/.claude/skills/add-whatsapp/tests/whatsapp.test.ts b/.claude/skills/add-whatsapp/tests/whatsapp.test.ts
new file mode 100644
index 0000000..619c91f
--- /dev/null
+++ b/.claude/skills/add-whatsapp/tests/whatsapp.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('whatsapp skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: whatsapp');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('@whiskeysockets/baileys');
+ });
+
+ it('has all files declared in adds', () => {
+ const channelFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.ts');
+ expect(fs.existsSync(channelFile)).toBe(true);
+
+ const content = fs.readFileSync(channelFile, 'utf-8');
+ expect(content).toContain('class WhatsAppChannel');
+ expect(content).toContain('implements Channel');
+ expect(content).toContain("registerChannel('whatsapp'");
+
+ // Test file for the channel
+ const testFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.test.ts');
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const testContent = fs.readFileSync(testFile, 'utf-8');
+ expect(testContent).toContain("describe('WhatsAppChannel'");
+
+ // Auth script (runtime)
+ const authFile = path.join(skillDir, 'add', 'src', 'whatsapp-auth.ts');
+ expect(fs.existsSync(authFile)).toBe(true);
+
+ // Auth setup step
+ const setupAuthFile = path.join(skillDir, 'add', 'setup', 'whatsapp-auth.ts');
+ expect(fs.existsSync(setupAuthFile)).toBe(true);
+
+ const setupAuthContent = fs.readFileSync(setupAuthFile, 'utf-8');
+ expect(setupAuthContent).toContain('WhatsApp interactive auth');
+ });
+
+ it('has all files declared in modifies', () => {
+ // Channel barrel file
+ const indexFile = path.join(skillDir, 'modify', 'src', 'channels', 'index.ts');
+ expect(fs.existsSync(indexFile)).toBe(true);
+
+ const indexContent = fs.readFileSync(indexFile, 'utf-8');
+ expect(indexContent).toContain("import './whatsapp.js'");
+
+ // Setup index (adds whatsapp-auth step)
+ const setupIndexFile = path.join(skillDir, 'modify', 'setup', 'index.ts');
+ expect(fs.existsSync(setupIndexFile)).toBe(true);
+
+ const setupIndexContent = fs.readFileSync(setupIndexFile, 'utf-8');
+ expect(setupIndexContent).toContain("'whatsapp-auth'");
+ });
+
+ it('has intent files for modified files', () => {
+ expect(
+ fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md')),
+ ).toBe(true);
+ expect(
+ fs.existsSync(path.join(skillDir, 'modify', 'setup', 'index.ts.intent.md')),
+ ).toBe(true);
+ });
+});
diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md
index e4e8a13..024f8d5 100644
--- a/.claude/skills/setup/SKILL.md
+++ b/.claude/skills/setup/SKILL.md
@@ -1,13 +1,13 @@
---
name: setup
-description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate WhatsApp, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
+description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messaging channels, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
---
# NanoClaw Setup
-Run setup steps automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
+Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
-**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. scanning a QR code, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
+**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
**UX Note:** Use `AskUserQuestion` for all user-facing questions.
@@ -27,7 +27,7 @@ Run `bash setup.sh` and parse the status block.
Run `npx tsx setup/index.ts --step environment` and parse the status block.
-- If HAS_AUTH=true → note that WhatsApp auth exists, offer to skip step 5
+- If HAS_AUTH=true → WhatsApp is already configured, note for step 5
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record APPLE_CONTAINER and DOCKER values for step 3
@@ -38,12 +38,12 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
- PLATFORM=linux → Docker (only option)
-- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (default, cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
-- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker (default)
+- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 4c.
+- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker
### 3a-docker. Install Docker
-- DOCKER=running → continue to 3b
+- DOCKER=running → continue to 4b
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`.
- DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed:
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
@@ -59,9 +59,9 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.
-**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
+**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 4c.
-**If the chosen runtime is Docker**, no conversion is needed — Docker is the default. Continue to 3c.
+**If the chosen runtime is Docker**, no conversion is needed. Continue to 4c.
### 3c. Build and test
@@ -83,53 +83,40 @@ AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
**API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`.
-## 5. WhatsApp Authentication
+## 5. Set Up Channels
-If HAS_AUTH=true, confirm: keep or re-authenticate?
+AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
+- WhatsApp (authenticates via QR code or pairing code)
+- Telegram (authenticates via bot token from @BotFather)
+- Slack (authenticates via Slack app with Socket Mode)
+- Discord (authenticates via Discord bot token)
-**Choose auth method based on environment (from step 2):**
+**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct.
-If IS_HEADLESS=true AND IS_WSL=false → AskUserQuestion: Pairing code (recommended) vs QR code in terminal?
-Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal?
+For each selected channel, invoke its skill:
-- **QR browser:** `npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser` (Bash timeout: 150000ms)
-- **Pairing code:** Ask for phone number first. `npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone NUMBER` (Bash timeout: 150000ms). Display PAIRING_CODE.
-- **QR terminal:** `npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal`. Tell user to run `npm run auth` in another terminal.
+- **WhatsApp:** Invoke `/add-whatsapp`
+- **Telegram:** Invoke `/add-telegram`
+- **Slack:** Invoke `/add-slack`
+- **Discord:** Invoke `/add-discord`
-**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
+Each skill will:
+1. Install the channel code (via `apply-skill`)
+2. Collect credentials/tokens and write to `.env`
+3. Authenticate (WhatsApp QR/pairing, or verify token-based connection)
+4. Register the chat with the correct JID format
+5. Build and verify
-## 6. Configure Trigger and Channel Type
+**After all channel skills complete**, continue to step 6.
-Get bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
-
-AskUserQuestion: Shared number or dedicated? → AskUserQuestion: Trigger word? → AskUserQuestion: Main channel type?
-
-**Shared number:** Self-chat (recommended) or Solo group
-**Dedicated number:** DM with bot (recommended) or Solo group with bot
-
-## 7. Sync and Select Group (If Group Channel)
-
-**Personal chat:** JID = `NUMBER@s.whatsapp.net`
-**DM with bot:** Ask for bot's number, JID = `NUMBER@s.whatsapp.net`
-
-**Group:**
-1. `npx tsx setup/index.ts --step groups` (Bash timeout: 60000ms)
-2. BUILD=failed → fix TypeScript, re-run. GROUPS_IN_DB=0 → check logs.
-3. `npx tsx setup/index.ts --step groups -- --list` for pipe-separated JID|name lines.
-4. Present candidates as AskUserQuestion (names only, not JIDs).
-
-## 8. Register Channel
-
-Run `npx tsx setup/index.ts --step register -- --jid "JID" --name "main" --trigger "@TriggerWord" --folder "main"` plus `--no-trigger-required` if personal/DM/solo, `--assistant-name "Name"` if not Andy.
-
-## 9. Mount Allowlist
+## 6. Mount Allowlist
AskUserQuestion: Agent access to external directories?
**No:** `npx tsx setup/index.ts --step mounts -- --empty`
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
-## 10. Start Service
+## 7. Start Service
If service already running: unload first.
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
@@ -159,28 +146,28 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo`
- Linux: check `systemctl --user status nanoclaw`.
- Re-run the service step after fixing.
-## 11. Verify
+## 8. Verify
Run `npx tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each:**
- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
-- SERVICE=not_found → re-run step 10
+- SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4
-- WHATSAPP_AUTH=not_found → re-run step 5
-- REGISTERED_GROUPS=0 → re-run steps 7-8
+- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
+- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
## Troubleshooting
-**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 10), missing `.env` (step 4), missing auth (step 5).
+**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`.
-**WhatsApp disconnected:** `npm run auth` then rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux).
+**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change.
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
diff --git a/.gitignore b/.gitignore
index deda421..e259fbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,9 @@ groups/global/*
*.keys.json
.env
+# Temp files
+.tmp-*
+
# OS
.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3833a1..c1a0f85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
# Changelog
All notable changes to NanoClaw will be documented in this file.
+
+## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0)
+
+[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved).
diff --git a/CLAUDE.md b/CLAUDE.md
index d0ae601..c96b95d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,14 +4,14 @@ Personal Claude assistant. See [README.md](README.md) for philosophy and setup.
## Quick Context
-Single Node.js process that connects to WhatsApp, routes messages to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory.
+Single Node.js process with skill-based channel system. Channels (WhatsApp, Telegram, Slack, Discord, Gmail) are skills that self-register at startup. Messages route to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory.
## Key Files
| File | Purpose |
|------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
-| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
+| `src/channels/registry.ts` | Channel registry (self-registration at startup) |
| `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing |
| `src/config.ts` | Trigger pattern, paths, intervals |
@@ -55,6 +55,10 @@ systemctl --user stop nanoclaw
systemctl --user restart nanoclaw
```
+## Troubleshooting
+
+**WhatsApp not connecting after upgrade:** WhatsApp is now a separate skill, not bundled in core. Run `/add-whatsapp` (or `npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp && npm run build`) to install it. Existing auth credentials and groups are preserved.
+
## Container Build Cache
The container 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, prune the builder then re-run `./container/build.sh`.
diff --git a/README.md b/README.md
index d8318ca..5655c61 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,6 @@
•
-
Using Claude Code, NanoClaw can dynamically rewrite its code to customize its feature set for your needs.
**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.
@@ -33,6 +32,8 @@ claude
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration.
+> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal.
+
## Philosophy
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
@@ -54,7 +55,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
## What It Supports
-- **Messenger I/O** - Message NanoClaw from your phone. Supports WhatsApp, Telegram, Discord, Slack, Signal and headless operation.
+- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time.
- **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 to it.
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
@@ -106,7 +107,7 @@ Users then run `/add-telegram` on their fork and get clean code that does exactl
Skills we'd like to see:
**Communication Channels**
-- `/add-slack` - Add Slack
+- `/add-signal` - Add Signal as a channel
**Session Management**
- `/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.
@@ -121,14 +122,16 @@ Skills we'd like to see:
## Architecture
```
-WhatsApp (baileys) --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
+Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
```
-Single Node.js process. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
+Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
+
+For the full architecture details, see [docs/SPEC.md](docs/SPEC.md).
Key files:
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
-- `src/channels/whatsapp.ts` - WhatsApp connection, auth, send/receive
+- `src/channels/registry.ts` - Channel registry (self-registration at startup)
- `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
@@ -191,6 +194,10 @@ This keeps the base system minimal and lets every user customize their installat
Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
+## Changelog
+
+See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes.
+
## License
MIT
diff --git a/README_zh.md b/README_zh.md
index bd2be5c..a05265a 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -12,14 +12,15 @@
•
+通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
## 我为什么创建这个项目
-[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,愿景宏大。但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有 52+ 个模块、8 个配置管理文件、45+ 个依赖项,以及为 15 个渠道提供商设计的抽象层。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
+[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
-NanoClaw 用一个您能在 8 分钟内理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
+NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
## 快速开始
@@ -31,25 +32,27 @@ claude
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。
+> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
+
## 设计哲学
**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。
-**为单一用户打造:** 这不是一个框架,是一个完全符合我个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
+**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
-**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
+**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。
-**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。工具套件至关重要。一个低效的工具套件会让再聪明的模型也显得迟钝,而一个优秀的套件则能赋予它们超凡的能力。Claude Code (在我看来) 是市面上最好的工具套件。
+**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。
## 功能支持
-- **WhatsApp 输入/输出** - 通过手机给 Claude 发消息
+- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息
@@ -101,15 +104,10 @@ claude
我们希望看到的技能:
**通信渠道**
-- `/add-telegram` - 添加 Telegram 作为渠道。应提供选项让用户选择替换 WhatsApp 或作为额外渠道添加。也应能将其添加为控制渠道(可以触发动作)或仅作为被其他地方触发的动作所使用的渠道。
-- `/add-slack` - 添加 Slack
-- `/add-discord` - 添加 Discord
-
-**平台支持**
-- `/setup-windows` - 通过 WSL2 + Docker 支持 Windows
+- `/add-signal` - 添加 Signal 作为渠道
**会话管理**
-- `/add-clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
+- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
## 系统要求
@@ -121,17 +119,19 @@ claude
## 架构
```
-WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
+渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
```
-单一 Node.js 进程。智能体在具有挂载目录的隔离 Linux 容器中执行。每个群组的消息队列都带有全局并发控制。通过文件系统进行进程间通信(IPC)。
+单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。
+
+完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。
关键文件:
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
-- `src/channels/whatsapp.ts` - WhatsApp 连接、认证、收发消息
+- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
- `src/ipc.ts` - IPC 监听与任务处理
- `src/router.ts` - 消息格式化与出站路由
-- `src/group-queue.ts` - 各带全局并发限制的群组队列
+- `src/group-queue.ts` - 带全局并发限制的群组队列
- `src/container-runner.ts` - 生成流式智能体容器
- `src/task-scheduler.ts` - 运行计划任务
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态)
@@ -139,10 +139,6 @@ WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) -->
## FAQ
-**为什么是 WhatsApp 而不是 Telegram/Signal 等?**
-
-因为我用 WhatsApp。Fork 这个项目然后运行一个技能来改变它。正是这个项目的核心理念。
-
**为什么是 Docker?**
Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。
@@ -181,7 +177,7 @@ ANTHROPIC_AUTH_TOKEN=your-token-here
**为什么我的安装不成功?**
-我不知道。运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 `SKILL.md` 安装文件。
+如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。
**什么样的代码更改会被接受?**
@@ -195,6 +191,10 @@ ANTHROPIC_AUTH_TOKEN=your-token-here
有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。
+## 更新日志
+
+破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
+
## 许可证
MIT
diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts
index 006b812..fc1236d 100644
--- a/container/agent-runner/src/ipc-mcp-stdio.ts
+++ b/container/agent-runner/src/ipc-mcp-stdio.ts
@@ -246,13 +246,13 @@ server.tool(
server.tool(
'register_group',
- `Register a new WhatsApp group so the agent can respond to messages there. Main group only.
+ `Register a new chat/group so the agent can respond to messages there. Main group only.
-Use available_groups.json to find the JID for a group. The folder name should be lowercase with hyphens (e.g., "family-chat").`,
+Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
{
- jid: z.string().describe('The WhatsApp JID (e.g., "120363336345536173@g.us")'),
+ jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
name: z.string().describe('Display name for the group'),
- folder: z.string().describe('Folder name for group files (lowercase, hyphens, e.g., "family-chat")'),
+ folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
},
async (args) => {
diff --git a/docs/SPEC.md b/docs/SPEC.md
index b439012..d2b4723 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -1,79 +1,81 @@
# NanoClaw Specification
-A personal Claude assistant accessible via WhatsApp, with persistent memory per conversation, scheduled tasks, and email integration.
+A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
## 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)
+2. [Architecture: Channel System](#architecture-channel-system)
+3. [Folder Structure](#folder-structure)
+4. [Configuration](#configuration)
+5. [Memory System](#memory-system)
+6. [Session Management](#session-management)
+7. [Message Flow](#message-flow)
+8. [Commands](#commands)
+9. [Scheduled Tasks](#scheduled-tasks)
+10. [MCP Servers](#mcp-servers)
+11. [Deployment](#deployment)
+12. [Security Considerations](#security-considerations)
---
## Architecture
```
-┌─────────────────────────────────────────────────────────────────────┐
-│ HOST (macOS) │
-│ (Main Node.js Process) │
-├─────────────────────────────────────────────────────────────────────┤
-│ │
-│ ┌──────────────┐ ┌────────────────────┐ │
-│ │ WhatsApp │────────────────────▶│ SQLite Database │ │
-│ │ (baileys) │◀────────────────────│ (messages.db) │ │
-│ └──────────────┘ store/send └─────────┬──────────┘ │
-│ │ │
-│ ┌────────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
-│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
-│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │
-│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
-│ │ │ │
-│ └───────────┬───────────┘ │
-│ │ spawns container │
-│ ▼ │
-├─────────────────────────────────────────────────────────────────────┤
-│ CONTAINER (Linux VM) │
-├─────────────────────────────────────────────────────────────────────┤
-│ ┌──────────────────────────────────────────────────────────────┐ │
-│ │ AGENT RUNNER │ │
-│ │ │ │
-│ │ Working directory: /workspace/group (mounted from host) │ │
-│ │ Volume mounts: │ │
-│ │ • groups/{name}/ → /workspace/group │ │
-│ │ • groups/global/ → /workspace/global/ (non-main only) │ │
-│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
-│ │ • Additional dirs → /workspace/extra/* │ │
-│ │ │ │
-│ │ Tools (all groups): │ │
-│ │ • Bash (safe - sandboxed in container!) │ │
-│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │
-│ │ • WebSearch, WebFetch (internet access) │ │
-│ │ • agent-browser (browser automation) │ │
-│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │
-│ │ │ │
-│ └──────────────────────────────────────────────────────────────┘ │
-│ │
-└──────────────────────────────────────────────────────────────────────┘
+┌──────────────────────────────────────────────────────────────────────┐
+│ HOST (macOS / Linux) │
+│ (Main Node.js Process) │
+├──────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────────┐ ┌────────────────────┐ │
+│ │ Channels │─────────────────▶│ SQLite Database │ │
+│ │ (self-register │◀────────────────│ (messages.db) │ │
+│ │ at startup) │ store/send └─────────┬──────────┘ │
+│ └──────────────────┘ │ │
+│ │ │
+│ ┌─────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
+│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
+│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │
+│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
+│ │ │ │
+│ └───────────┬───────────┘ │
+│ │ spawns container │
+│ ▼ │
+├──────────────────────────────────────────────────────────────────────┤
+│ CONTAINER (Linux VM) │
+├──────────────────────────────────────────────────────────────────────┤
+│ ┌──────────────────────────────────────────────────────────────┐ │
+│ │ AGENT RUNNER │ │
+│ │ │ │
+│ │ Working directory: /workspace/group (mounted from host) │ │
+│ │ Volume mounts: │ │
+│ │ • groups/{name}/ → /workspace/group │ │
+│ │ • groups/global/ → /workspace/global/ (non-main only) │ │
+│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
+│ │ • Additional dirs → /workspace/extra/* │ │
+│ │ │ │
+│ │ Tools (all groups): │ │
+│ │ • Bash (safe - sandboxed in container!) │ │
+│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │
+│ │ • WebSearch, WebFetch (internet access) │ │
+│ │ • agent-browser (browser automation) │ │
+│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │
+│ │ │ │
+│ └──────────────────────────────────────────────────────────────┘ │
+│ │
+└───────────────────────────────────────────────────────────────────────┘
```
### Technology Stack
| Component | Technology | Purpose |
|-----------|------------|---------|
-| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
+| Channel System | Channel registry (`src/channels/registry.ts`) | Channels self-register at startup |
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
| Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution |
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers |
@@ -82,6 +84,158 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per
---
+## Architecture: Channel System
+
+The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a [Claude Code skill](https://code.claude.com/docs/en/skills) that adds the channel code to your fork. Channels self-register at startup; installed channels with missing credentials emit a WARN log and are skipped.
+
+### System Diagram
+
+```mermaid
+graph LR
+ subgraph Channels["Channels"]
+ WA[WhatsApp]
+ TG[Telegram]
+ SL[Slack]
+ DC[Discord]
+ New["Other Channel (Signal, Gmail...)"]
+ end
+
+ subgraph Orchestrator["Orchestrator — index.ts"]
+ ML[Message Loop]
+ GQ[Group Queue]
+ RT[Router]
+ TS[Task Scheduler]
+ DB[(SQLite)]
+ end
+
+ subgraph Execution["Container Execution"]
+ CR[Container Runner]
+ LC["Linux Container"]
+ IPC[IPC Watcher]
+ end
+
+ %% Flow
+ WA & TG & SL & DC & New -->|onMessage| ML
+ ML --> GQ
+ GQ -->|concurrency| CR
+ CR --> LC
+ LC -->|filesystem IPC| IPC
+ IPC -->|tasks & messages| RT
+ RT -->|Channel.sendMessage| Channels
+ TS -->|due tasks| CR
+
+ %% DB Connections
+ DB <--> ML
+ DB <--> TS
+
+ %% Styling for the dynamic channel
+ style New stroke-dasharray: 5 5,stroke-width:2px
+```
+
+### Channel Registry
+
+The channel system is built on a factory registry in `src/channels/registry.ts`:
+
+```typescript
+export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
+
+const registry = new Map();
+
+export function registerChannel(name: string, factory: ChannelFactory): void {
+ registry.set(name, factory);
+}
+
+export function getChannelFactory(name: string): ChannelFactory | undefined {
+ return registry.get(name);
+}
+
+export function getRegisteredChannelNames(): string[] {
+ return [...registry.keys()];
+}
+```
+
+Each factory receives `ChannelOpts` (callbacks for `onMessage`, `onChatMetadata`, and `registeredGroups`) and returns either a `Channel` instance or `null` if that channel's credentials are not configured.
+
+### Channel Interface
+
+Every channel implements this interface (defined in `src/types.ts`):
+
+```typescript
+interface Channel {
+ name: string;
+ connect(): Promise;
+ sendMessage(jid: string, text: string): Promise;
+ isConnected(): boolean;
+ ownsJid(jid: string): boolean;
+ disconnect(): Promise;
+ setTyping?(jid: string, isTyping: boolean): Promise;
+ syncGroups?(force: boolean): Promise;
+}
+```
+
+### Self-Registration Pattern
+
+Channels self-register using a barrel-import pattern:
+
+1. Each channel skill adds a file to `src/channels/` (e.g. `whatsapp.ts`, `telegram.ts`) that calls `registerChannel()` at module load time:
+
+ ```typescript
+ // src/channels/whatsapp.ts
+ import { registerChannel, ChannelOpts } from './registry.js';
+
+ export class WhatsAppChannel implements Channel { /* ... */ }
+
+ registerChannel('whatsapp', (opts: ChannelOpts) => {
+ // Return null if credentials are missing
+ if (!existsSync(authPath)) return null;
+ return new WhatsAppChannel(opts);
+ });
+ ```
+
+2. The barrel file `src/channels/index.ts` imports all channel modules, triggering registration:
+
+ ```typescript
+ import './whatsapp.js';
+ import './telegram.js';
+ // ... each skill adds its import here
+ ```
+
+3. At startup, the orchestrator (`src/index.ts`) loops through registered channels and connects whichever ones return a valid instance:
+
+ ```typescript
+ for (const name of getRegisteredChannelNames()) {
+ const factory = getChannelFactory(name);
+ const channel = factory?.(channelOpts);
+ if (channel) {
+ await channel.connect();
+ channels.push(channel);
+ }
+ }
+ ```
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `src/channels/registry.ts` | Channel factory registry |
+| `src/channels/index.ts` | Barrel imports that trigger channel self-registration |
+| `src/types.ts` | `Channel` interface, `ChannelOpts`, message types |
+| `src/index.ts` | Orchestrator — instantiates channels, runs message loop |
+| `src/router.ts` | Finds the owning channel for a JID, formats messages |
+
+### Adding a New Channel
+
+To add a new channel, contribute a skill to `.claude/skills/add-/` that:
+
+1. Adds a `src/channels/.ts` file implementing the `Channel` interface
+2. Calls `registerChannel(name, factory)` at module load
+3. Returns `null` from the factory if credentials are missing
+4. Adds an import line to `src/channels/index.ts`
+
+See existing skills (`/add-whatsapp`, `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`) for the pattern.
+
+---
+
## Folder Structure
```
@@ -100,7 +254,8 @@ nanoclaw/
├── src/
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── channels/
-│ │ └── whatsapp.ts # WhatsApp connection, auth, send/receive
+│ │ ├── registry.ts # Channel factory registry
+│ │ └── index.ts # Barrel imports for channel self-registration
│ ├── ipc.ts # IPC watcher and task processing
│ ├── router.ts # Message formatting and outbound routing
│ ├── config.ts # Configuration constants
@@ -141,10 +296,10 @@ nanoclaw/
│
├── groups/
│ ├── CLAUDE.md # Global memory (all groups read this)
-│ ├── main/ # Self-chat (main control channel)
+│ ├── {channel}_main/ # Main control channel (e.g., whatsapp_main/)
│ │ ├── CLAUDE.md # Main channel memory
│ │ └── logs/ # Task execution logs
-│ └── {Group Name}/ # Per-group folders (created on registration)
+│ └── {channel}_{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
@@ -205,7 +360,7 @@ Groups can have additional directories mounted via `containerConfig` in the SQLi
```typescript
registerGroup("1234567890@g.us", {
name: "Dev Team",
- folder: "dev-team",
+ folder: "whatsapp_dev-team",
trigger: "@Andy",
added_at: new Date().toISOString(),
containerConfig: {
@@ -221,6 +376,8 @@ registerGroup("1234567890@g.us", {
});
```
+Folder names follow the convention `{channel}_{group-name}` (e.g., `whatsapp_family-chat`, `telegram_dev-team`). The main group has `isMain: true` set during registration.
+
Additional mounts appear at `/workspace/extra/{containerPath}` inside the container.
**Mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix may not work on all runtimes).
@@ -314,10 +471,10 @@ Sessions enable conversation continuity - Claude remembers what you talked about
### Incoming Message Flow
```
-1. User sends WhatsApp message
+1. User sends a message via any connected channel
│
▼
-2. Baileys receives message via WhatsApp Web protocol
+2. Channel receives message (e.g. Baileys for WhatsApp, Bot API for Telegram)
│
▼
3. Message stored in SQLite (store/messages.db)
@@ -349,7 +506,7 @@ Sessions enable conversation continuity - Claude remembers what you talked about
└── Uses tools as needed (search, email, etc.)
│
▼
-9. Router prefixes response with assistant name and sends via WhatsApp
+9. Router prefixes response with assistant name and sends via the owning channel
│
▼
10. Router updates last agent timestamp and saves session ID
@@ -473,7 +630,7 @@ The `nanoclaw` MCP server is created dynamically per agent call with the current
| `pause_task` | Pause a task |
| `resume_task` | Resume a paused task |
| `cancel_task` | Delete a task |
-| `send_message` | Send a WhatsApp message to the group |
+| `send_message` | Send a message to the group via its channel |
---
@@ -487,7 +644,8 @@ When NanoClaw starts, it:
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
2. Initializes the SQLite database (migrates from JSON files if they exist)
3. Loads state from SQLite (registered groups, sessions, router state)
-4. Connects to WhatsApp (on `connection.open`):
+4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each
+5. Once at least one channel is connected:
- Starts the scheduler loop
- Starts the IPC watcher for container messages
- Sets up the per-group queue with `processGroupMessages`
diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md
index 9ae1b13..e7aa25a 100644
--- a/groups/main/CLAUDE.md
+++ b/groups/main/CLAUDE.md
@@ -119,13 +119,13 @@ sqlite3 /workspace/project/store/messages.db "
### Registered Groups Config
-Groups are registered in `/workspace/project/data/registered_groups.json`:
+Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
- "folder": "family-chat",
+ "folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
@@ -133,32 +133,34 @@ Groups are registered in `/workspace/project/data/registered_groups.json`:
```
Fields:
-- **Key**: The WhatsApp JID (unique identifier for the chat)
+- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
-- **folder**: Folder name under `groups/` for this group's files and memory
+- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
+- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
-- **Main group**: No trigger needed — all messages are processed automatically
+- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
-2. Read `/workspace/project/data/registered_groups.json`
-3. Add the new group entry with `containerConfig` if needed
-4. Write the updated JSON back
-5. Create the group folder: `/workspace/project/groups/{folder-name}/`
-6. Optionally create an initial `CLAUDE.md` for the group
+2. Use the `register_group` MCP tool with the JID, name, folder, and trigger
+3. Optionally include `containerConfig` for additional mounts
+4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
+5. Optionally create an initial `CLAUDE.md` for the group
-Example folder name conventions:
-- "Family Chat" → `family-chat`
-- "Work Team" → `work-team`
-- Use lowercase, hyphens instead of spaces
+Folder naming convention — channel prefix with underscore separator:
+- WhatsApp "Family Chat" → `whatsapp_family-chat`
+- Telegram "Dev Team" → `telegram_dev-team`
+- Discord "General" → `discord_general`
+- Slack "Engineering" → `slack_engineering`
+- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
diff --git a/package-lock.json b/package-lock.json
index ed1f6cc..baeffb5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,27 +1,23 @@
{
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"dependencies": {
- "@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
- "qrcode": "^1.5.4",
- "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
- "@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"prettier": "^3.8.1",
@@ -93,62 +89,6 @@
"node": ">=18"
}
},
- "node_modules/@borewit/text-codec": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
- "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@cacheable/memory": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
- "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/utils": "^2.3.3",
- "@keyv/bigmap": "^1.3.0",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- }
- },
- "node_modules/@cacheable/node-cache": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
- "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
- "license": "MIT",
- "dependencies": {
- "cacheable": "^2.3.1",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@cacheable/utils": {
- "version": "2.3.4",
- "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz",
- "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.3.0",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -591,486 +531,6 @@
"node": ">=18"
}
},
- "node_modules/@hapi/boom": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
- "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "9.x.x"
- }
- },
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@img/colour": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
- "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "cpu": [
- "ppc64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "cpu": [
- "riscv64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "cpu": [
- "s390x"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "cpu": [
- "ppc64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "cpu": [
- "riscv64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "cpu": [
- "s390x"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/runtime": "^1.7.0"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "cpu": [
- "ia32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1099,98 +559,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@keyv/bigmap": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
- "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.4.0",
- "hookified": "^1.15.0"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@keyv/serialize": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
- "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
- "license": "MIT"
- },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
- "node_modules/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
- }
- },
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -1548,29 +922,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@tokenizer/inflate": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
- "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.3",
- "token-types": "^6.1.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT"
- },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1606,28 +957,16 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/long": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
- "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
- "license": "MIT"
- },
"node_modules/@types/node": {
"version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/qrcode-terminal": {
- "version": "0.12.2",
- "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
- "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@vitest/coverage-v8": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
@@ -1770,69 +1109,6 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@whiskeysockets/baileys": {
- "version": "7.0.0-rc.9",
- "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
- "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@cacheable/node-cache": "^1.4.0",
- "@hapi/boom": "^9.1.3",
- "async-mutex": "^0.5.0",
- "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
- "lru-cache": "^11.1.0",
- "music-metadata": "^11.7.0",
- "p-queue": "^9.0.0",
- "pino": "^9.6",
- "protobufjs": "^7.2.4",
- "ws": "^8.13.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "audio-decode": "^2.1.3",
- "jimp": "^1.6.0",
- "link-preview-js": "^3.0.0",
- "sharp": "*"
- },
- "peerDependenciesMeta": {
- "audio-decode": {
- "optional": true
- },
- "jimp": {
- "optional": true
- },
- "link-preview-js": {
- "optional": true
- }
- }
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1855,15 +1131,6 @@
"js-tokens": "^10.0.0"
}
},
- "node_modules/async-mutex": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
- "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1948,28 +1215,6 @@
"ieee754": "^1.1.13"
}
},
- "node_modules/cacheable": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
- "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/memory": "^2.0.7",
- "@cacheable/utils": "^2.3.3",
- "hookified": "^1.15.0",
- "keyv": "^5.5.5",
- "qified": "^0.6.0"
- }
- },
- "node_modules/camelcase": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
- "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1986,50 +1231,12 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
- "node_modules/cliui": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
- "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.0",
- "wrap-ansi": "^6.2.0"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "license": "MIT"
- },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
@@ -2042,12 +1249,6 @@
"node": ">=18"
}
},
- "node_modules/curve25519-js": {
- "version": "0.0.4",
- "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
- "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
- "license": "MIT"
- },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -2057,32 +1258,6 @@
"node": "*"
}
},
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/decamelize": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
- "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -2116,18 +1291,6 @@
"node": ">=8"
}
},
- "node_modules/dijkstrajs": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
- "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
- "license": "MIT"
- },
- "node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -2196,12 +1359,6 @@
"@types/estree": "^1.0.0"
}
},
- "node_modules/eventemitter3": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
- "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
- "license": "MIT"
- },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -2251,43 +1408,12 @@
}
}
},
- "node_modules/file-type": {
- "version": "21.3.0",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
- "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/inflate": "^0.4.1",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.1",
- "uint8array-extras": "^1.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
- "node_modules/find-up": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
- "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
- "license": "MIT",
- "dependencies": {
- "locate-path": "^5.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -2309,15 +1435,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -2347,30 +1464,12 @@
"node": ">=8"
}
},
- "node_modules/hashery": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz",
- "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
- "node_modules/hookified": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
- "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
- "license": "MIT"
- },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -2426,15 +1525,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2490,91 +1580,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/keyv": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
- "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@keyv/serialize": "^1.1.1"
- }
- },
- "node_modules/libsignal": {
- "name": "@whiskeysockets/libsignal-node",
- "version": "2.0.1",
- "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
- "license": "GPL-3.0",
- "dependencies": {
- "curve25519-js": "^0.0.4",
- "protobufjs": "6.8.8"
- }
- },
- "node_modules/libsignal/node_modules/@types/node": {
- "version": "10.17.60",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
- "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
- "license": "MIT"
- },
- "node_modules/libsignal/node_modules/long": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
- "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
- "license": "Apache-2.0"
- },
- "node_modules/libsignal/node_modules/protobufjs": {
- "version": "6.8.8",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
- "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/long": "^4.0.0",
- "@types/node": "^10.1.0",
- "long": "^4.0.0"
- },
- "bin": {
- "pbjs": "bin/pbjs",
- "pbts": "bin/pbts"
- }
- },
- "node_modules/locate-path": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
- "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
- "license": "MIT",
- "dependencies": {
- "p-locate": "^4.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/long": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
- },
- "node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@@ -2622,15 +1627,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/media-typer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -2658,43 +1654,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/music-metadata": {
- "version": "11.12.0",
- "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.0.tgz",
- "integrity": "sha512-9ChYnmVmyHvFxR2g0MWFSHmJfbssRy07457G4gbb4LA9WYvyZea/8EMbqvg5dcv4oXNCNL01m8HXtymLlhhkYg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- },
- {
- "type": "buymeacoffee",
- "url": "https://buymeacoffee.com/borewit"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "content-type": "^1.0.5",
- "debug": "^4.4.3",
- "file-type": "^21.3.0",
- "media-typer": "^1.1.0",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.2",
- "uint8array-extras": "^1.5.0",
- "win-guid": "^0.2.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2761,79 +1720,6 @@
"wrappy": "1"
}
},
- "node_modules/p-limit": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
- "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
- "license": "MIT",
- "dependencies": {
- "p-try": "^2.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
- "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
- "license": "MIT",
- "dependencies": {
- "p-limit": "^2.2.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/p-queue": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
- "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
- "license": "MIT",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "p-timeout": "^7.0.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-timeout": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
- "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-try": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
- "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2932,15 +1818,6 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
- "node_modules/pngjs": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
- "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
- "license": "MIT",
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3028,30 +1905,6 @@
],
"license": "MIT"
},
- "node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/node": ">=13.7.0",
- "long": "^5.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@@ -3062,43 +1915,6 @@
"once": "^1.3.1"
}
},
- "node_modules/qified": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
- "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/qrcode": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
- "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
- "license": "MIT",
- "dependencies": {
- "dijkstrajs": "^1.0.1",
- "pngjs": "^5.0.0",
- "yargs": "^15.3.1"
- },
- "bin": {
- "qrcode": "bin/qrcode"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/qrcode-terminal": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
- "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
- "bin": {
- "qrcode-terminal": "bin/qrcode-terminal.js"
- }
- },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -3152,21 +1968,6 @@
"node": ">= 12.13.0"
}
},
- "node_modules/require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/require-main-filename": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
- "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
- "license": "ISC"
- },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -3279,57 +2080,6 @@
"node": ">=10"
}
},
- "node_modules/set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
- "license": "ISC"
- },
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
- }
- },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3433,32 +2183,6 @@
"safe-buffer": "~5.2.0"
}
},
- "node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
@@ -3471,22 +2195,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strtok3": {
- "version": "10.3.4",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
- "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/token": "^0.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3581,30 +2289,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/token-types": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
- "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
- },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -3652,22 +2336,11 @@
"node": ">=14.17"
}
},
- "node_modules/uint8array-extras": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
- "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
@@ -3831,12 +2504,6 @@
}
}
},
- "node_modules/which-module": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
- "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
- "license": "ISC"
- },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3854,59 +2521,12 @@
"node": ">=8"
}
},
- "node_modules/win-guid": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
- "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
- "license": "MIT"
- },
- "node_modules/wrap-ansi": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
- "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
- "node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/y18n": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
- "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
- "license": "ISC"
- },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
@@ -3923,41 +2543,6 @@
"url": "https://github.com/sponsors/eemeli"
}
},
- "node_modules/yargs": {
- "version": "15.4.1",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
- "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^6.0.0",
- "decamelize": "^1.2.0",
- "find-up": "^4.1.0",
- "get-caller-file": "^2.0.1",
- "require-directory": "^2.1.1",
- "require-main-filename": "^2.0.0",
- "set-blocking": "^2.0.0",
- "string-width": "^4.2.0",
- "which-module": "^2.0.0",
- "y18n": "^4.0.0",
- "yargs-parser": "^18.1.2"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs-parser": {
- "version": "18.1.3",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
- "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
- "license": "ISC",
- "dependencies": {
- "camelcase": "^5.0.0",
- "decamelize": "^1.2.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
diff --git a/package.json b/package.json
index cad0604..6f78b4c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
@@ -8,31 +8,27 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
- "auth": "tsx src/whatsapp-auth.ts",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:fix": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"prepare": "husky",
"setup": "tsx setup/index.ts",
+ "auth": "tsx src/whatsapp-auth.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
- "@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
- "qrcode": "^1.5.4",
- "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
- "@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"prettier": "^3.8.1",
diff --git a/setup/environment.test.ts b/setup/environment.test.ts
index b33f272..deda62f 100644
--- a/setup/environment.test.ts
+++ b/setup/environment.test.ts
@@ -104,9 +104,8 @@ describe('Docker detection logic', () => {
});
});
-describe('WhatsApp auth detection', () => {
- it('detects non-empty auth directory logic', () => {
- // Simulate the check: directory exists and has files
+describe('channel auth detection', () => {
+ it('detects non-empty auth directory', () => {
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
@@ -119,3 +118,4 @@ describe('WhatsApp auth detection', () => {
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});
+
diff --git a/setup/groups.ts b/setup/groups.ts
index d251d5d..6697029 100644
--- a/setup/groups.ts
+++ b/setup/groups.ts
@@ -1,5 +1,7 @@
/**
- * Step: groups — Connect to WhatsApp, fetch group metadata, write to DB.
+ * Step: groups — Fetch group metadata from messaging platforms, write to DB.
+ * WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
+ * Other channels discover group names at runtime — this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
@@ -62,6 +64,25 @@ async function listGroups(limit: number): Promise {
}
async function syncGroups(projectRoot: string): Promise {
+ // Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
+ // Detect WhatsApp by checking for auth credentials on disk.
+ const authDir = path.join(projectRoot, 'store', 'auth');
+ const hasWhatsAppAuth =
+ fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
+
+ if (!hasWhatsAppAuth) {
+ logger.info('WhatsApp auth not found — skipping group sync');
+ emitStatus('SYNC_GROUPS', {
+ BUILD: 'skipped',
+ SYNC: 'skipped',
+ GROUPS_IN_DB: 0,
+ REASON: 'whatsapp_not_configured',
+ STATUS: 'success',
+ LOG: 'logs/setup.log',
+ });
+ return;
+ }
+
// Build TypeScript first
logger.info('Building TypeScript');
let buildOk = false;
@@ -85,7 +106,7 @@ async function syncGroups(projectRoot: string): Promise {
process.exit(1);
}
- // Run inline sync script via node
+ // Run sync script via a temp file to avoid shell escaping issues with node -e
logger.info('Fetching group metadata');
let syncOk = false;
try {
@@ -158,17 +179,20 @@ sock.ev.on('connection.update', async (update) => {
});
`;
- const output = execSync(
- `node --input-type=module -e ${JSON.stringify(syncScript)}`,
- {
+ const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
+ fs.writeFileSync(tmpScript, syncScript, 'utf-8');
+ try {
+ const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
- },
- );
- syncOk = output.includes('SYNCED:');
- logger.info({ output: output.trim() }, 'Sync output');
+ });
+ syncOk = output.includes('SYNCED:');
+ logger.info({ output: output.trim() }, 'Sync output');
+ } finally {
+ try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
+ }
} catch (err) {
logger.error({ err }, 'Sync failed');
}
diff --git a/setup/index.ts b/setup/index.ts
index 287a790..7ac13e2 100644
--- a/setup/index.ts
+++ b/setup/index.ts
@@ -11,7 +11,6 @@ const STEPS: Record<
> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
- 'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
diff --git a/setup/register.test.ts b/setup/register.test.ts
index 7258445..d47d95c 100644
--- a/setup/register.test.ts
+++ b/setup/register.test.ts
@@ -18,7 +18,8 @@ function createTestDb(): Database.Database {
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
- requires_trigger INTEGER DEFAULT 1
+ requires_trigger INTEGER DEFAULT 1,
+ is_main INTEGER DEFAULT 0
)`);
return db;
}
@@ -130,6 +131,49 @@ describe('parameterized SQL registration', () => {
expect(row.requires_trigger).toBe(0);
});
+ it('stores is_main flag', () => {
+ db.prepare(
+ `INSERT OR REPLACE INTO registered_groups
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
+ ).run(
+ '789@s.whatsapp.net',
+ 'Personal',
+ 'whatsapp_main',
+ '@Andy',
+ '2024-01-01T00:00:00.000Z',
+ 0,
+ 1,
+ );
+
+ const row = db
+ .prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
+ .get('789@s.whatsapp.net') as { is_main: number };
+
+ expect(row.is_main).toBe(1);
+ });
+
+ it('defaults is_main to 0', () => {
+ db.prepare(
+ `INSERT OR REPLACE INTO registered_groups
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
+ VALUES (?, ?, ?, ?, ?, NULL, ?)`,
+ ).run(
+ '123@g.us',
+ 'Some Group',
+ 'whatsapp_some-group',
+ '@Andy',
+ '2024-01-01T00:00:00.000Z',
+ 1,
+ );
+
+ const row = db
+ .prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
+ .get('123@g.us') as { is_main: number };
+
+ expect(row.is_main).toBe(0);
+ });
+
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
diff --git a/setup/register.ts b/setup/register.ts
index 55c3569..03ea7df 100644
--- a/setup/register.ts
+++ b/setup/register.ts
@@ -1,8 +1,8 @@
/**
* Step: register — Write channel registration config, create group folders.
- * Replaces 06-register-channel.sh
*
- * Fixes: SQL injection (parameterized queries), sed -i '' (uses fs directly).
+ * Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord).
+ * Uses parameterized SQL queries to prevent injection.
*/
import fs from 'fs';
import path from 'path';
@@ -19,7 +19,9 @@ interface RegisterArgs {
name: string;
trigger: string;
folder: string;
+ channel: string;
requiresTrigger: boolean;
+ isMain: boolean;
assistantName: string;
}
@@ -29,7 +31,9 @@ function parseArgs(args: string[]): RegisterArgs {
name: '',
trigger: '',
folder: '',
+ channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel
requiresTrigger: true,
+ isMain: false,
assistantName: 'Andy',
};
@@ -47,9 +51,15 @@ function parseArgs(args: string[]): RegisterArgs {
case '--folder':
result.folder = args[++i] || '';
break;
+ case '--channel':
+ result.channel = (args[++i] || '').toLowerCase();
+ break;
case '--no-trigger-required':
result.requiresTrigger = false;
break;
+ case '--is-main':
+ result.isMain = true;
+ break;
case '--assistant-name':
result.assistantName = args[++i] || 'Andy';
break;
@@ -83,8 +93,10 @@ export async function run(args: string[]): Promise {
logger.info(parsed, 'Registering channel');
- // Ensure data directory exists
+ // Ensure data and store directories exist (store/ may not exist on
+ // fresh installs that skip WhatsApp auth, which normally creates it)
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
+ fs.mkdirSync(STORE_DIR, { recursive: true });
// Write to SQLite using parameterized queries (no SQL injection)
const dbPath = path.join(STORE_DIR, 'messages.db');
@@ -100,13 +112,16 @@ export async function run(args: string[]): Promise {
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
- requires_trigger INTEGER DEFAULT 1
+ requires_trigger INTEGER DEFAULT 1,
+ is_main INTEGER DEFAULT 0
)`);
+ const isMainInt = parsed.isMain ? 1 : 0;
+
db.prepare(
`INSERT OR REPLACE INTO registered_groups
- (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
- VALUES (?, ?, ?, ?, ?, NULL, ?)`,
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(
parsed.jid,
parsed.name,
@@ -114,6 +129,7 @@ export async function run(args: string[]): Promise {
parsed.trigger,
timestamp,
requiresTriggerInt,
+ isMainInt,
);
db.close();
@@ -134,7 +150,7 @@ export async function run(args: string[]): Promise {
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
- path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
+ path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
];
for (const mdFile of mdFiles) {
@@ -174,6 +190,7 @@ export async function run(args: string[]): Promise {
JID: parsed.jid,
NAME: parsed.name,
FOLDER: parsed.folder,
+ CHANNEL: parsed.channel,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
diff --git a/setup/service.ts b/setup/service.ts
index 9e7932a..643c8c9 100644
--- a/setup/service.ts
+++ b/setup/service.ts
@@ -161,7 +161,7 @@ function setupLinux(
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
- * Prevents WhatsApp "conflict" disconnects when two instances connect simultaneously.
+ * Prevents connection conflicts when two instances connect to the same channel simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
try {
@@ -262,7 +262,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
);
}
- // Kill orphaned nanoclaw processes to avoid WhatsApp conflict errors
+ // Kill orphaned nanoclaw processes to avoid channel connection conflicts
killOrphanedProcesses(projectRoot);
// Enable and start
diff --git a/setup/verify.ts b/setup/verify.ts
index a08a431..f64e4d0 100644
--- a/setup/verify.ts
+++ b/setup/verify.ts
@@ -12,6 +12,7 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
+import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import {
getPlatform,
@@ -105,13 +106,39 @@ export async function run(_args: string[]): Promise {
}
}
- // 4. Check WhatsApp auth
- let whatsappAuth = 'not_found';
+ // 4. Check channel auth (detect configured channels by credentials)
+ const envVars = readEnvFile([
+ 'TELEGRAM_BOT_TOKEN',
+ 'SLACK_BOT_TOKEN',
+ 'SLACK_APP_TOKEN',
+ 'DISCORD_BOT_TOKEN',
+ ]);
+
+ const channelAuth: Record = {};
+
+ // WhatsApp: check for auth credentials on disk
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
- whatsappAuth = 'authenticated';
+ channelAuth.whatsapp = 'authenticated';
}
+ // Token-based channels: check .env
+ if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) {
+ channelAuth.telegram = 'configured';
+ }
+ if (
+ (process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) &&
+ (process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN)
+ ) {
+ channelAuth.slack = 'configured';
+ }
+ if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
+ channelAuth.discord = 'configured';
+ }
+
+ const configuredChannels = Object.keys(channelAuth);
+ const anyChannelConfigured = configuredChannels.length > 0;
+
// 5. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
@@ -142,18 +169,19 @@ export async function run(_args: string[]): Promise {
const status =
service === 'running' &&
credentials !== 'missing' &&
- whatsappAuth !== 'not_found' &&
+ anyChannelConfigured &&
registeredGroups > 0
? 'success'
: 'failed';
- logger.info({ status }, 'Verification complete');
+ logger.info({ status, channelAuth }, 'Verification complete');
emitStatus('VERIFY', {
SERVICE: service,
CONTAINER_RUNTIME: containerRuntime,
CREDENTIALS: credentials,
- WHATSAPP_AUTH: whatsappAuth,
+ CONFIGURED_CHANNELS: configuredChannels.join(','),
+ CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
diff --git a/src/channels/index.ts b/src/channels/index.ts
new file mode 100644
index 0000000..44f4f55
--- /dev/null
+++ b/src/channels/index.ts
@@ -0,0 +1,12 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+
+// slack
+
+// telegram
+
+// whatsapp
diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts
new file mode 100644
index 0000000..e47b1bf
--- /dev/null
+++ b/src/channels/registry.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import {
+ registerChannel,
+ getChannelFactory,
+ getRegisteredChannelNames,
+} from './registry.js';
+
+// The registry is module-level state, so we need a fresh module per test.
+// We use dynamic import with cache-busting to isolate tests.
+// However, since vitest runs each file in its own context and we control
+// registration order, we can test the public API directly.
+
+describe('channel registry', () => {
+ // Note: registry is shared module state across tests in this file.
+ // Tests are ordered to account for cumulative registrations.
+
+ it('getChannelFactory returns undefined for unknown channel', () => {
+ expect(getChannelFactory('nonexistent')).toBeUndefined();
+ });
+
+ it('registerChannel and getChannelFactory round-trip', () => {
+ const factory = () => null;
+ registerChannel('test-channel', factory);
+ expect(getChannelFactory('test-channel')).toBe(factory);
+ });
+
+ it('getRegisteredChannelNames includes registered channels', () => {
+ registerChannel('another-channel', () => null);
+ const names = getRegisteredChannelNames();
+ expect(names).toContain('test-channel');
+ expect(names).toContain('another-channel');
+ });
+
+ it('later registration overwrites earlier one', () => {
+ const factory1 = () => null;
+ const factory2 = () => null;
+ registerChannel('overwrite-test', factory1);
+ registerChannel('overwrite-test', factory2);
+ expect(getChannelFactory('overwrite-test')).toBe(factory2);
+ });
+});
diff --git a/src/channels/registry.ts b/src/channels/registry.ts
new file mode 100644
index 0000000..ab871c3
--- /dev/null
+++ b/src/channels/registry.ts
@@ -0,0 +1,28 @@
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+
+export interface ChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
+
+const registry = new Map();
+
+export function registerChannel(name: string, factory: ChannelFactory): void {
+ registry.set(name, factory);
+}
+
+export function getChannelFactory(name: string): ChannelFactory | undefined {
+ return registry.get(name);
+}
+
+export function getRegisteredChannelNames(): string[] {
+ return [...registry.keys()];
+}
diff --git a/src/config.ts b/src/config.ts
index 8a4cb92..d57205b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -30,7 +30,6 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
-export const MAIN_GROUP_FOLDER = 'main';
export const CONTAINER_IMAGE =
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
diff --git a/src/db.test.ts b/src/db.test.ts
index e7f772c..3051bce 100644
--- a/src/db.test.ts
+++ b/src/db.test.ts
@@ -5,9 +5,11 @@ import {
createTask,
deleteTask,
getAllChats,
+ getAllRegisteredGroups,
getMessagesSince,
getNewMessages,
getTaskById,
+ setRegisteredGroup,
storeChatMetadata,
storeMessage,
updateTask,
@@ -388,3 +390,37 @@ describe('task CRUD', () => {
expect(getTaskById('task-3')).toBeUndefined();
});
});
+
+// --- RegisteredGroup isMain round-trip ---
+
+describe('registered group isMain', () => {
+ it('persists isMain=true through set/get round-trip', () => {
+ setRegisteredGroup('main@s.whatsapp.net', {
+ name: 'Main Chat',
+ folder: 'whatsapp_main',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ isMain: true,
+ });
+
+ const groups = getAllRegisteredGroups();
+ const group = groups['main@s.whatsapp.net'];
+ expect(group).toBeDefined();
+ expect(group.isMain).toBe(true);
+ expect(group.folder).toBe('whatsapp_main');
+ });
+
+ it('omits isMain for non-main groups', () => {
+ setRegisteredGroup('group@g.us', {
+ name: 'Family Chat',
+ folder: 'whatsapp_family-chat',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ });
+
+ const groups = getAllRegisteredGroups();
+ const group = groups['group@g.us'];
+ expect(group).toBeDefined();
+ expect(group.isMain).toBeUndefined();
+ });
+});
diff --git a/src/db.ts b/src/db.ts
index 9d9a4d5..09d786f 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -106,6 +106,19 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
+ // Add is_main column if it doesn't exist (migration for existing DBs)
+ try {
+ database.exec(
+ `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
+ );
+ // Backfill: existing rows with folder = 'main' are the main group
+ database.exec(
+ `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
+ );
+ } catch {
+ /* column already exists */
+ }
+
// Add channel and is_group columns if they don't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
@@ -263,7 +276,7 @@ export function storeMessage(msg: NewMessage): void {
}
/**
- * Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
+ * Store a message directly.
*/
export function storeMessageDirect(msg: {
id: string;
@@ -530,6 +543,7 @@ export function getRegisteredGroup(
added_at: string;
container_config: string | null;
requires_trigger: number | null;
+ is_main: number | null;
}
| undefined;
if (!row) return undefined;
@@ -551,6 +565,7 @@ export function getRegisteredGroup(
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
+ isMain: row.is_main === 1 ? true : undefined,
};
}
@@ -559,8 +574,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
db.prepare(
- `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
group.name,
@@ -569,6 +584,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
group.added_at,
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
+ group.isMain ? 1 : 0,
);
}
@@ -581,6 +597,7 @@ export function getAllRegisteredGroups(): Record {
added_at: string;
container_config: string | null;
requires_trigger: number | null;
+ is_main: number | null;
}>;
const result: Record = {};
for (const row of rows) {
@@ -601,6 +618,7 @@ export function getAllRegisteredGroups(): Record {
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
+ isMain: row.is_main === 1 ? true : undefined,
};
}
return result;
diff --git a/src/index.ts b/src/index.ts
index 278a7a7..234be79 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,11 +4,14 @@ import path from 'path';
import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
- MAIN_GROUP_FOLDER,
POLL_INTERVAL,
TRIGGER_PATTERN,
} from './config.js';
-import { WhatsAppChannel } from './channels/whatsapp.js';
+import './channels/index.js';
+import {
+ getChannelFactory,
+ getRegisteredChannelNames,
+} from './channels/registry.js';
import {
ContainerOutput,
runContainerAgent,
@@ -51,7 +54,6 @@ let registeredGroups: Record = {};
let lastAgentTimestamp: Record = {};
let messageLoopRunning = false;
-let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
@@ -140,7 +142,7 @@ async function processGroupMessages(chatJid: string): Promise {
return true;
}
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const isMainGroup = group.isMain === true;
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince(
@@ -250,7 +252,7 @@ async function runAgent(
chatJid: string,
onOutput?: (output: ContainerOutput) => Promise,
): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const isMain = group.isMain === true;
const sessionId = sessions[group.folder];
// Update tasks snapshot for container to read (filtered by group)
@@ -371,7 +373,7 @@ async function startMessageLoop(): Promise {
continue;
}
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const isMainGroup = group.isMain === true;
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
@@ -474,10 +476,26 @@ async function main(): Promise {
registeredGroups: () => registeredGroups,
};
- // Create and connect channels
- whatsapp = new WhatsAppChannel(channelOpts);
- channels.push(whatsapp);
- await whatsapp.connect();
+ // Create and connect all registered channels.
+ // Each channel self-registers via the barrel import above.
+ // Factories return null when credentials are missing, so unconfigured channels are skipped.
+ for (const channelName of getRegisteredChannelNames()) {
+ const factory = getChannelFactory(channelName)!;
+ const channel = factory(channelOpts);
+ if (!channel) {
+ logger.warn(
+ { channel: channelName },
+ 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
+ );
+ continue;
+ }
+ channels.push(channel);
+ await channel.connect();
+ }
+ if (channels.length === 0) {
+ logger.fatal('No channels connected');
+ process.exit(1);
+ }
// Start subsystems (independently of connection handler)
startSchedulerLoop({
@@ -504,8 +522,13 @@ async function main(): Promise {
},
registeredGroups: () => registeredGroups,
registerGroup,
- syncGroupMetadata: (force) =>
- whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
+ syncGroups: async (force: boolean) => {
+ await Promise.all(
+ channels
+ .filter((ch) => ch.syncGroups)
+ .map((ch) => ch.syncGroups!(force)),
+ );
+ },
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) =>
writeGroupsSnapshot(gf, im, ag, rj),
diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts
index e155d44..7edc7db 100644
--- a/src/ipc-auth.test.ts
+++ b/src/ipc-auth.test.ts
@@ -14,9 +14,10 @@ import { RegisteredGroup } from './types.js';
// Set up registered groups used across tests
const MAIN_GROUP: RegisteredGroup = {
name: 'Main',
- folder: 'main',
+ folder: 'whatsapp_main',
trigger: 'always',
added_at: '2024-01-01T00:00:00.000Z',
+ isMain: true,
};
const OTHER_GROUP: RegisteredGroup = {
@@ -58,7 +59,7 @@ beforeEach(() => {
setRegisteredGroup(jid, group);
// Mock the fs.mkdirSync that registerGroup does
},
- syncGroupMetadata: async () => {},
+ syncGroups: async () => {},
getAvailableGroups: () => [],
writeGroupsSnapshot: () => {},
};
@@ -76,7 +77,7 @@ describe('schedule_task authorization', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -133,7 +134,7 @@ describe('schedule_task authorization', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'unknown@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -149,7 +150,7 @@ describe('pause_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-main',
- group_folder: 'main',
+ group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
@@ -176,7 +177,7 @@ describe('pause_task authorization', () => {
it('main group can pause any task', async () => {
await processTaskIpc(
{ type: 'pause_task', taskId: 'task-other' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -225,7 +226,7 @@ describe('resume_task authorization', () => {
it('main group can resume any task', async () => {
await processTaskIpc(
{ type: 'resume_task', taskId: 'task-paused' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -272,7 +273,7 @@ describe('cancel_task authorization', () => {
await processTaskIpc(
{ type: 'cancel_task', taskId: 'task-to-cancel' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -305,7 +306,7 @@ describe('cancel_task authorization', () => {
it('non-main group cannot cancel another groups task', async () => {
createTask({
id: 'task-foreign',
- group_folder: 'main',
+ group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
@@ -356,7 +357,7 @@ describe('register_group authorization', () => {
folder: '../../outside',
trigger: '@Andy',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -397,8 +398,12 @@ describe('IPC message authorization', () => {
}
it('main group can send to any group', () => {
- expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true);
- expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true);
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups),
+ ).toBe(true);
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups),
+ ).toBe(true);
});
it('non-main group can send to its own chat', () => {
@@ -424,9 +429,9 @@ describe('IPC message authorization', () => {
it('main group can send to unregistered JID', () => {
// Main is always authorized regardless of target
- expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(
- true,
- );
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
+ ).toBe(true);
});
});
@@ -442,7 +447,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '0 9 * * *', // every day at 9am
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -466,7 +471,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'not a cron',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -485,7 +490,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '3600000', // 1 hour
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -508,7 +513,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'abc',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -525,7 +530,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '0',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -542,7 +547,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'not-a-date',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -564,7 +569,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'group',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -583,7 +588,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'isolated',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -602,7 +607,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -620,7 +625,7 @@ describe('schedule_task context_mode', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -642,7 +647,7 @@ describe('register_group success', () => {
folder: 'new-group',
trigger: '@Andy',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -663,7 +668,7 @@ describe('register_group success', () => {
name: 'Partial',
// missing folder and trigger
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
diff --git a/src/ipc.ts b/src/ipc.ts
index 52cf7d7..d410685 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -3,12 +3,7 @@ import path from 'path';
import { CronExpressionParser } from 'cron-parser';
-import {
- DATA_DIR,
- IPC_POLL_INTERVAL,
- MAIN_GROUP_FOLDER,
- TIMEZONE,
-} from './config.js';
+import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
import { AvailableGroup } from './container-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { isValidGroupFolder } from './group-folder.js';
@@ -19,7 +14,7 @@ export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise;
registeredGroups: () => Record;
registerGroup: (jid: string, group: RegisteredGroup) => void;
- syncGroupMetadata: (force: boolean) => Promise;
+ syncGroups: (force: boolean) => Promise;
getAvailableGroups: () => AvailableGroup[];
writeGroupsSnapshot: (
groupFolder: string,
@@ -57,8 +52,14 @@ export function startIpcWatcher(deps: IpcDeps): void {
const registeredGroups = deps.registeredGroups();
+ // Build folder→isMain lookup from registered groups
+ const folderIsMain = new Map();
+ for (const group of Object.values(registeredGroups)) {
+ if (group.isMain) folderIsMain.set(group.folder, true);
+ }
+
for (const sourceGroup of groupFolders) {
- const isMain = sourceGroup === MAIN_GROUP_FOLDER;
+ const isMain = folderIsMain.get(sourceGroup) === true;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
@@ -331,7 +332,7 @@ export async function processTaskIpc(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
- await deps.syncGroupMetadata(true);
+ await deps.syncGroups(true);
// Write updated snapshot immediately
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(
@@ -365,6 +366,7 @@ export async function processTaskIpc(
);
break;
}
+ // Defense in depth: agent cannot set isMain via IPC
deps.registerGroup(data.jid, {
name: data.name,
folder: data.folder,
diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts
index f6cfa72..e4f606f 100644
--- a/src/task-scheduler.ts
+++ b/src/task-scheduler.ts
@@ -2,12 +2,7 @@ import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
-import {
- ASSISTANT_NAME,
- MAIN_GROUP_FOLDER,
- SCHEDULER_POLL_INTERVAL,
- TIMEZONE,
-} from './config.js';
+import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
import {
ContainerOutput,
runContainerAgent,
@@ -94,7 +89,7 @@ async function runTask(
}
// Update tasks snapshot for container to read (filtered by group)
- const isMain = task.group_folder === MAIN_GROUP_FOLDER;
+ const isMain = group.isMain === true;
const tasks = getAllTasks();
writeTasksSnapshot(
task.group_folder,
diff --git a/src/types.ts b/src/types.ts
index 7038b3a..acbb08a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -39,6 +39,7 @@ export interface RegisteredGroup {
added_at: string;
containerConfig?: ContainerConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
+ isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
}
export interface NewMessage {
@@ -87,6 +88,8 @@ export interface Channel {
disconnect(): Promise;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise;
+ // Optional: sync group/chat names from the platform.
+ syncGroups?(force: boolean): Promise;
}
// Callback type that channels use to deliver inbound messages
@@ -94,7 +97,7 @@ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
-// channels that sync names separately (WhatsApp syncGroupMetadata) omit it.
+// channels that sync names separately (via syncGroups) omit it.
export type OnChatMetadata = (
chatJid: string,
timestamp: string,