Merge branch 'main' into docs/k8s-image-gc-known-issue
This commit is contained in:
1
.claude/settings.json
Normal file
1
.claude/settings.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
135
.claude/skills/add-compact/SKILL.md
Normal file
135
.claude/skills/add-compact/SKILL.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: add-compact
|
||||
description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only.
|
||||
---
|
||||
|
||||
# Add /compact Command
|
||||
|
||||
Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts.
|
||||
|
||||
**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
Check if `src/session-commands.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Merge the skill branch:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/compact
|
||||
git merge upstream/skill/compact
|
||||
```
|
||||
|
||||
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
|
||||
|
||||
This adds:
|
||||
- `src/session-commands.ts` (extract and authorize session commands)
|
||||
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
|
||||
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
|
||||
- Slash command handling in `container/agent-runner/src/index.ts`
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Rebuild container
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Integration Test
|
||||
|
||||
1. Start NanoClaw in dev mode: `npm run dev`
|
||||
2. From the **main group** (self-chat), send exactly: `/compact`
|
||||
3. Verify:
|
||||
- The agent acknowledges compaction (e.g., "Conversation compacted.")
|
||||
- The session continues — send a follow-up message and verify the agent responds coherently
|
||||
- A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook)
|
||||
- Container logs show `Compact boundary observed` (confirms SDK actually compacted)
|
||||
- If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed"
|
||||
4. From a **non-main group** as a non-admin user, send: `@<assistant> /compact`
|
||||
5. Verify:
|
||||
- The bot responds with "Session commands require admin access."
|
||||
- No compaction occurs, no container is spawned for the command
|
||||
6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@<assistant> /compact`
|
||||
7. Verify:
|
||||
- Compaction proceeds normally (same behavior as main group)
|
||||
8. While an **active container** is running for the main group, send `/compact`
|
||||
9. Verify:
|
||||
- The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work)
|
||||
- Compaction proceeds via a new container once the active one exits
|
||||
- The command is not dropped (no cursor race)
|
||||
10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch):
|
||||
11. Verify:
|
||||
- Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls)
|
||||
- Compaction proceeds after pre-compact messages are processed
|
||||
- Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle
|
||||
12. From a **non-main group** as a non-admin user, send `@<assistant> /compact`:
|
||||
13. Verify:
|
||||
- Denial message is sent ("Session commands require admin access.")
|
||||
- The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls
|
||||
- Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval)
|
||||
- No container is killed or interrupted
|
||||
14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix):
|
||||
15. Verify:
|
||||
- No denial message is sent (trigger policy prevents untrusted bot responses)
|
||||
- The `/compact` is consumed silently
|
||||
- Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable
|
||||
16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it
|
||||
|
||||
### Validation on Fresh Clone
|
||||
|
||||
```bash
|
||||
git clone <your-fork> /tmp/nanoclaw-test
|
||||
cd /tmp/nanoclaw-test
|
||||
claude # then run /add-compact
|
||||
npm run build
|
||||
npm test
|
||||
./container/build.sh
|
||||
# Manual: send /compact from main group, verify compaction + continuation
|
||||
# Manual: send @<assistant> /compact from non-main as non-admin, verify denial
|
||||
# Manual: send @<assistant> /compact from non-main as admin, verify allowed
|
||||
# Manual: verify no auto-compaction behavior
|
||||
```
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group.
|
||||
- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill.
|
||||
- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl.
|
||||
- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it.
|
||||
- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context.
|
||||
|
||||
## What This Does NOT Do
|
||||
|
||||
- No automatic compaction threshold (add separately if desired)
|
||||
- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset)
|
||||
- No cross-group compaction (each group's session is isolated)
|
||||
- No changes to the container image, Dockerfile, or build script
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied.
|
||||
- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded.
|
||||
- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt.
|
||||
@@ -1,64 +1,66 @@
|
||||
---
|
||||
name: add-discord
|
||||
description: Add Discord bot channel integration to NanoClaw.
|
||||
---
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
This skill adds Discord support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
|
||||
This skill adds Discord support to NanoClaw, then walks through interactive setup.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `discord` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
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.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
|
||||
|
||||
### Apply the skill
|
||||
If `discord` is missing, add it:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-discord
|
||||
git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `src/channels/discord.ts` (DiscordChannel class implementing Channel interface)
|
||||
- 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`
|
||||
- Installs the `discord.js` npm dependency
|
||||
- Updates `.env.example` with `DISCORD_BOT_TOKEN` and `DISCORD_ONLY`
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
### Merge the skill branch
|
||||
|
||||
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
|
||||
```bash
|
||||
git fetch discord main
|
||||
git merge discord/main || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/discord.test.ts` (unit tests with discord.js mock)
|
||||
- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- `discord.js` npm dependency in `package.json`
|
||||
- `DISCORD_BOT_TOKEN` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/channels/discord.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the new Discord tests) and build must be clean before proceeding.
|
||||
@@ -93,16 +95,12 @@ Add to `.env`:
|
||||
DISCORD_BOT_TOKEN=<their-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.
|
||||
@@ -132,30 +130,18 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
|
||||
|
||||
### Register the channel
|
||||
|
||||
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
|
||||
The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
|
||||
|
||||
For a main channel (responds to all messages, uses the `main` folder):
|
||||
For a main channel (responds to all messages):
|
||||
|
||||
```typescript
|
||||
registerGroup("dc:<channel-id>", {
|
||||
name: "<server-name> #<channel-name>",
|
||||
folder: "main",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
|
||||
```
|
||||
|
||||
For additional channels (trigger-only):
|
||||
|
||||
```typescript
|
||||
registerGroup("dc:<channel-id>", {
|
||||
name: "<server-name> #<channel-name>",
|
||||
folder: "<folder-name>",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: true,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel discord
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
@@ -1,762 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- discord.js mock ---
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
const Events = {
|
||||
MessageCreate: 'messageCreate',
|
||||
ClientReady: 'ready',
|
||||
Error: 'error',
|
||||
};
|
||||
|
||||
const GatewayIntentBits = {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 4,
|
||||
DirectMessages: 8,
|
||||
};
|
||||
|
||||
class MockClient {
|
||||
eventHandlers = new Map<string, Handler[]>();
|
||||
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||
private _ready = false;
|
||||
|
||||
constructor(_opts: any) {
|
||||
clientRef.current = this;
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
const existing = this.eventHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
this.eventHandlers.set(event, existing);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: Handler) {
|
||||
return this.on(event, handler);
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
this._ready = true;
|
||||
// Fire the ready event
|
||||
const readyHandlers = this.eventHandlers.get('ready') || [];
|
||||
for (const h of readyHandlers) {
|
||||
h({ user: this.user });
|
||||
}
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
channels = {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this._ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock TextChannel type
|
||||
class TextChannel {}
|
||||
|
||||
return {
|
||||
Client: MockClient,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
TextChannel,
|
||||
};
|
||||
});
|
||||
|
||||
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(
|
||||
overrides?: Partial<DiscordChannelOpts>,
|
||||
): DiscordChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'dc:1234567890123456': {
|
||||
name: 'Test Server #general',
|
||||
folder: 'test-server',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: {
|
||||
channelId?: string;
|
||||
content?: string;
|
||||
authorId?: string;
|
||||
authorUsername?: string;
|
||||
authorDisplayName?: string;
|
||||
memberDisplayName?: string;
|
||||
isBot?: boolean;
|
||||
guildName?: string;
|
||||
channelName?: string;
|
||||
messageId?: string;
|
||||
createdAt?: Date;
|
||||
attachments?: Map<string, any>;
|
||||
reference?: { messageId?: string };
|
||||
mentionsBotId?: boolean;
|
||||
}) {
|
||||
const channelId = overrides.channelId ?? '1234567890123456';
|
||||
const authorId = overrides.authorId ?? '55512345';
|
||||
const botId = '999888777'; // matches mock client user id
|
||||
|
||||
const mentionsMap = new Map();
|
||||
if (overrides.mentionsBotId) {
|
||||
mentionsMap.set(botId, { id: botId });
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
id: overrides.messageId ?? 'msg_001',
|
||||
content: overrides.content ?? 'Hello everyone',
|
||||
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
|
||||
author: {
|
||||
id: authorId,
|
||||
username: overrides.authorUsername ?? 'alice',
|
||||
displayName: overrides.authorDisplayName ?? 'Alice',
|
||||
bot: overrides.isBot ?? false,
|
||||
},
|
||||
member: overrides.memberDisplayName
|
||||
? { displayName: overrides.memberDisplayName }
|
||||
: null,
|
||||
guild: overrides.guildName
|
||||
? { name: overrides.guildName }
|
||||
: null,
|
||||
channel: {
|
||||
name: overrides.channelName ?? 'general',
|
||||
messages: {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
author: { username: 'Bob', displayName: 'Bob' },
|
||||
member: { displayName: 'Bob' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
mentions: {
|
||||
users: mentionsMap,
|
||||
},
|
||||
attachments: overrides.attachments ?? new Map(),
|
||||
reference: overrides.reference ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function currentClient() {
|
||||
return clientRef.current;
|
||||
}
|
||||
|
||||
async function triggerMessage(message: any) {
|
||||
const handlers = currentClient().eventHandlers.get('messageCreate') || [];
|
||||
for (const h of handlers) await h(message);
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('DiscordChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('resolves connect() when client is ready', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('registers message handlers on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
|
||||
expect(currentClient().eventHandlers.has('error')).toBe(true);
|
||||
expect(currentClient().eventHandlers.has('ready')).toBe(true);
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected() returns false before connect', () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text message handling ---
|
||||
|
||||
describe('text message handling', () => {
|
||||
it('delivers message for registered channel', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello everyone',
|
||||
guildName: 'Test Server',
|
||||
channelName: 'general',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'Test Server #general',
|
||||
);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
id: 'msg_001',
|
||||
chat_jid: 'dc:1234567890123456',
|
||||
sender: '55512345',
|
||||
sender_name: 'Alice',
|
||||
content: 'Hello everyone',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only emits metadata for unregistered channels', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
channelId: '9999999999999999',
|
||||
content: 'Unknown channel',
|
||||
guildName: 'Other Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:9999999999999999',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
);
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores bot messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({ isBot: true, content: 'I am a bot' });
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses member displayName when available (server nickname)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hi',
|
||||
memberDisplayName: 'Alice Nickname',
|
||||
authorDisplayName: 'Alice Global',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({ sender_name: 'Alice Nickname' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to author displayName when no member', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hi',
|
||||
memberDisplayName: undefined,
|
||||
authorDisplayName: 'Alice Global',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({ sender_name: 'Alice Global' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses sender name for DM chats (no guild)', async () => {
|
||||
const opts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'dc:1234567890123456': {
|
||||
name: 'DM',
|
||||
folder: 'dm',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello',
|
||||
guildName: undefined,
|
||||
authorDisplayName: 'Alice',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'Alice',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses guild name + channel name for server messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello',
|
||||
guildName: 'My Server',
|
||||
channelName: 'bot-chat',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'My Server #bot-chat',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- @mention translation ---
|
||||
|
||||
describe('@mention translation', () => {
|
||||
it('translates <@botId> mention to trigger format', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '<@999888777> what time is it?',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy what time is it?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate if message already matches trigger', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '@Andy hello <@999888777>',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
// Should NOT prepend @Andy — already starts with trigger
|
||||
// But the <@botId> should still be stripped
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy hello',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate when bot is not mentioned', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'hello everyone',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: 'hello everyone',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles <@!botId> (nickname mention format)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '<@!999888777> check this',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy check this',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
describe('attachments', () => {
|
||||
it('stores image attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'photo.png', contentType: 'image/png' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Image: photo.png]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores video attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Video: clip.mp4]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores file attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[File: report.pdf]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes text content with attachments', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: 'Check this out',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: 'Check this out\n[Image: photo.jpg]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple attachments', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'a.png', contentType: 'image/png' }],
|
||||
['att2', { name: 'b.txt', contentType: 'text/plain' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Image: a.png]\n[File: b.txt]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reply context ---
|
||||
|
||||
describe('reply context', () => {
|
||||
it('includes reply author in content', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'I agree with that',
|
||||
reference: { messageId: 'original_msg_id' },
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Reply to Bob] I agree with that',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- sendMessage ---
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('sends message via channel', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
||||
|
||||
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
|
||||
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
|
||||
});
|
||||
|
||||
it('strips dc: prefix from JID', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('dc:9876543210', 'Test');
|
||||
|
||||
expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
|
||||
});
|
||||
|
||||
it('handles send failure gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
currentClient().channels.fetch.mockRejectedValueOnce(
|
||||
new Error('Channel not found'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
channel.sendMessage('dc:1234567890123456', 'Will fail'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when client is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
// Don't connect — client is null
|
||||
await channel.sendMessage('dc:1234567890123456', 'No client');
|
||||
|
||||
// No error, no API call
|
||||
});
|
||||
|
||||
it('splits messages exceeding 2000 characters', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
const longText = 'x'.repeat(3000);
|
||||
await channel.sendMessage('dc:1234567890123456', longText);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledTimes(2);
|
||||
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
|
||||
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('owns dc: JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp group JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own Telegram JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('tg:123456789')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own unknown JID formats', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends typing indicator when isTyping is true', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const mockChannel = {
|
||||
send: vi.fn(),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.setTyping('dc:1234567890123456', true);
|
||||
|
||||
expect(mockChannel.sendTyping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when isTyping is false', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.setTyping('dc:1234567890123456', false);
|
||||
|
||||
// channels.fetch should NOT be called
|
||||
expect(currentClient().channels.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when client is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
// Don't connect
|
||||
await channel.setTyping('dc:1234567890123456', true);
|
||||
|
||||
// No error
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
it('has name "discord"', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.name).toBe('discord');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
|
||||
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
Channel,
|
||||
OnChatMetadata,
|
||||
OnInboundMessage,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
export interface DiscordChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class DiscordChannel implements Channel {
|
||||
name = 'discord';
|
||||
|
||||
private client: Client | null = null;
|
||||
private opts: DiscordChannelOpts;
|
||||
private botToken: string;
|
||||
|
||||
constructor(botToken: string, opts: DiscordChannelOpts) {
|
||||
this.botToken = botToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
});
|
||||
|
||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||
// Ignore bot messages (including own)
|
||||
if (message.author.bot) return;
|
||||
|
||||
const channelId = message.channelId;
|
||||
const chatJid = `dc:${channelId}`;
|
||||
let content = message.content;
|
||||
const timestamp = message.createdAt.toISOString();
|
||||
const senderName =
|
||||
message.member?.displayName ||
|
||||
message.author.displayName ||
|
||||
message.author.username;
|
||||
const sender = message.author.id;
|
||||
const msgId = message.id;
|
||||
|
||||
// Determine chat name
|
||||
let chatName: string;
|
||||
if (message.guild) {
|
||||
const textChannel = message.channel as TextChannel;
|
||||
chatName = `${message.guild.name} #${textChannel.name}`;
|
||||
} else {
|
||||
chatName = senderName;
|
||||
}
|
||||
|
||||
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
|
||||
// Discord mentions look like <@botUserId> — these won't match
|
||||
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
|
||||
// when the bot is @mentioned.
|
||||
if (this.client?.user) {
|
||||
const botId = this.client.user.id;
|
||||
const isBotMentioned =
|
||||
message.mentions.users.has(botId) ||
|
||||
content.includes(`<@${botId}>`) ||
|
||||
content.includes(`<@!${botId}>`);
|
||||
|
||||
if (isBotMentioned) {
|
||||
// Strip the <@botId> mention to avoid visual clutter
|
||||
content = content
|
||||
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
|
||||
.trim();
|
||||
// Prepend trigger if not already present
|
||||
if (!TRIGGER_PATTERN.test(content)) {
|
||||
content = `@${ASSISTANT_NAME} ${content}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments — store placeholders so the agent knows something was sent
|
||||
if (message.attachments.size > 0) {
|
||||
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
|
||||
const contentType = att.contentType || '';
|
||||
if (contentType.startsWith('image/')) {
|
||||
return `[Image: ${att.name || 'image'}]`;
|
||||
} else if (contentType.startsWith('video/')) {
|
||||
return `[Video: ${att.name || 'video'}]`;
|
||||
} else if (contentType.startsWith('audio/')) {
|
||||
return `[Audio: ${att.name || 'audio'}]`;
|
||||
} else {
|
||||
return `[File: ${att.name || 'file'}]`;
|
||||
}
|
||||
});
|
||||
if (content) {
|
||||
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
||||
} else {
|
||||
content = attachmentDescriptions.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reply context — include who the user is replying to
|
||||
if (message.reference?.messageId) {
|
||||
try {
|
||||
const repliedTo = await message.channel.messages.fetch(
|
||||
message.reference.messageId,
|
||||
);
|
||||
const replyAuthor =
|
||||
repliedTo.member?.displayName ||
|
||||
repliedTo.author.displayName ||
|
||||
repliedTo.author.username;
|
||||
content = `[Reply to ${replyAuthor}] ${content}`;
|
||||
} catch {
|
||||
// Referenced message may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Store chat metadata for discovery
|
||||
this.opts.onChatMetadata(chatJid, timestamp, chatName);
|
||||
|
||||
// Only deliver full message for registered groups
|
||||
const group = this.opts.registeredGroups()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug(
|
||||
{ chatJid, chatName },
|
||||
'Message from unregistered Discord channel',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deliver message — startMessageLoop() will pick it up
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msgId,
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ chatJid, chatName, sender: senderName },
|
||||
'Discord message stored',
|
||||
);
|
||||
});
|
||||
|
||||
// Handle errors gracefully
|
||||
this.client.on(Events.Error, (err) => {
|
||||
logger.error({ err: err.message }, 'Discord client error');
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.client!.once(Events.ClientReady, (readyClient) => {
|
||||
logger.info(
|
||||
{ username: readyClient.user.tag, id: readyClient.user.id },
|
||||
'Discord bot connected',
|
||||
);
|
||||
console.log(`\n Discord bot: ${readyClient.user.tag}`);
|
||||
console.log(
|
||||
` Use /chatid command or check channel IDs in Discord settings\n`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client!.login(this.botToken);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.warn('Discord client not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
|
||||
if (!channel || !('send' in channel)) {
|
||||
logger.warn({ jid }, 'Discord channel not found or not text-based');
|
||||
return;
|
||||
}
|
||||
|
||||
const textChannel = channel as TextChannel;
|
||||
|
||||
// Discord has a 2000 character limit per message — split if needed
|
||||
const MAX_LENGTH = 2000;
|
||||
if (text.length <= MAX_LENGTH) {
|
||||
await textChannel.send(text);
|
||||
} else {
|
||||
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||
await textChannel.send(text.slice(i, i + MAX_LENGTH));
|
||||
}
|
||||
}
|
||||
logger.info({ jid, length: text.length }, 'Discord message sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send Discord message');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && this.client.isReady();
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('dc:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.destroy();
|
||||
this.client = null;
|
||||
logger.info('Discord bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
if (!this.client || !isTyping) return;
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (channel && 'sendTyping' in channel) {
|
||||
await (channel as TextChannel).sendTyping();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
skill: discord
|
||||
version: 1.0.0
|
||||
description: "Discord Bot integration via discord.js"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/channels/discord.ts
|
||||
- src/channels/discord.test.ts
|
||||
modifies:
|
||||
- src/index.ts
|
||||
- src/config.ts
|
||||
- src/routing.test.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"
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
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<string, RegisteredGroup>): 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<boolean> {
|
||||
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<typeof setTimeout> | 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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<void>,
|
||||
): 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<void> {
|
||||
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<string, NewMessage[]>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('discord 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: discord');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('discord.js');
|
||||
});
|
||||
|
||||
it('has all files declared in adds', () => {
|
||||
const addFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.ts');
|
||||
expect(fs.existsSync(addFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(addFile, 'utf-8');
|
||||
expect(content).toContain('class DiscordChannel');
|
||||
expect(content).toContain('implements Channel');
|
||||
|
||||
// Test file for the channel
|
||||
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.test.ts');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain("describe('DiscordChannel'");
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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:");
|
||||
});
|
||||
});
|
||||
289
.claude/skills/add-emacs/SKILL.md
Normal file
289
.claude/skills/add-emacs/SKILL.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: add-emacs
|
||||
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
|
||||
---
|
||||
|
||||
# Add Emacs Channel
|
||||
|
||||
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
|
||||
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
|
||||
## What you can do with this
|
||||
|
||||
- **Ask while coding** — open the chat buffer (`C-c n c` / `SPC N c`), ask about a function or error without leaving Emacs
|
||||
- **Code review** — select a region and send it with `nanoclaw-org-send`; the response appears as a child heading inline in your org file
|
||||
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
|
||||
- **Draft writing** — send org prose; receive revisions or continuations in place
|
||||
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
|
||||
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/channels/emacs.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
|
||||
```
|
||||
|
||||
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/emacs
|
||||
git merge upstream/skill/emacs
|
||||
```
|
||||
|
||||
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
|
||||
version and continuing:
|
||||
|
||||
```bash
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
```
|
||||
|
||||
For any other conflict, read the conflicted file and reconcile both sides manually.
|
||||
|
||||
This adds:
|
||||
- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766)
|
||||
- `src/channels/emacs.test.ts` — unit tests
|
||||
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
|
||||
- `import './emacs.js'` appended to `src/channels/index.ts`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx vitest run src/channels/emacs.test.ts
|
||||
```
|
||||
|
||||
Build must be clean and tests must pass before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
### Configure environment (optional)
|
||||
|
||||
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
|
||||
|
||||
```bash
|
||||
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
|
||||
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
|
||||
```
|
||||
|
||||
If you change or add values, sync to the container environment:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
### Configure Emacs
|
||||
|
||||
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
|
||||
|
||||
AskUserQuestion: Which Emacs distribution are you using?
|
||||
- **Doom Emacs** - config.el with map! keybindings
|
||||
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
|
||||
- **Vanilla Emacs / other** - init.el with global-set-key
|
||||
|
||||
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
(load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
|
||||
(map! :leader
|
||||
:prefix ("N" . "NanoClaw")
|
||||
:desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
:desc "Send org" "o" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x doom/reload`
|
||||
|
||||
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
|
||||
(spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
|
||||
(global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x eval-buffer` or restart Emacs.
|
||||
|
||||
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token "<your-token>")
|
||||
```
|
||||
|
||||
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-port <your-port>)
|
||||
```
|
||||
|
||||
### Restart NanoClaw
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test the HTTP endpoint
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
Expected: `{"messages":[]}`
|
||||
|
||||
If you set `EMACS_AUTH_TOKEN`:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
### Test from Emacs
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
|
||||
> 2. Type a message and press `RET`
|
||||
> 3. A response from Andy should appear within a few seconds
|
||||
>
|
||||
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port already in use
|
||||
|
||||
```
|
||||
Error: listen EADDRINUSE: address already in use :::8766
|
||||
```
|
||||
|
||||
Either a stale NanoClaw process is running, or 8766 is taken by another app.
|
||||
|
||||
Find and kill the stale process:
|
||||
|
||||
```bash
|
||||
lsof -ti :8766 | xargs kill -9
|
||||
```
|
||||
|
||||
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
|
||||
|
||||
### No response from agent
|
||||
|
||||
Check:
|
||||
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
|
||||
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
3. Logs show activity: `tail -50 logs/nanoclaw.log`
|
||||
|
||||
If the group is not registered, it will be created automatically on the next NanoClaw restart.
|
||||
|
||||
### Auth token mismatch (401 Unauthorized)
|
||||
|
||||
Verify the token in Emacs matches `.env`:
|
||||
|
||||
```elisp
|
||||
;; M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
|
||||
|
||||
### nanoclaw.el not loading
|
||||
|
||||
Check the path is correct:
|
||||
|
||||
```bash
|
||||
ls ~/src/nanoclaw/emacs/nanoclaw.el
|
||||
```
|
||||
|
||||
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Agent Formatting
|
||||
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should
|
||||
output standard markdown — **not** org-mode syntax. The conversion handles:
|
||||
|
||||
| Markdown | Org-mode |
|
||||
|----------|----------|
|
||||
| `**bold**` | `*bold*` |
|
||||
| `*italic*` | `/italic/` |
|
||||
| `~~text~~` | `+text+` |
|
||||
| `` `code` `` | `~code~` |
|
||||
| ` ```lang ` | `#+begin_src lang` |
|
||||
|
||||
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
|
||||
and render incorrectly.
|
||||
|
||||
## Removal
|
||||
|
||||
To remove the Emacs channel:
|
||||
|
||||
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
|
||||
2. Remove `import './emacs.js'` from `src/channels/index.ts`
|
||||
3. Remove the NanoClaw block from your Emacs config file
|
||||
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
|
||||
6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -11,7 +11,7 @@ This skill adds Gmail support to NanoClaw — either as a tool (read, send, sear
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
@@ -24,66 +24,42 @@ AskUserQuestion: Should incoming emails be able to trigger the agent?
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### Path A: Tool-only (user chose "No")
|
||||
|
||||
Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency).
|
||||
|
||||
#### 1. Mount Gmail credentials in container
|
||||
|
||||
Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts.
|
||||
|
||||
#### 2. Add Gmail MCP server to agent runner
|
||||
|
||||
Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`.
|
||||
|
||||
#### 3. Record in state
|
||||
|
||||
Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`.
|
||||
|
||||
#### 4. Validate
|
||||
If `gmail` is missing, add it:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
|
||||
```
|
||||
|
||||
Build must be clean before proceeding. Skip to Phase 3.
|
||||
|
||||
### Path B: Channel mode (user chose "Yes")
|
||||
|
||||
Run the full skills engine to apply all code changes:
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-gmail
|
||||
git fetch gmail main
|
||||
git merge gmail/main || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
This merges in:
|
||||
- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/gmail.test.ts` (unit tests)
|
||||
- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts`
|
||||
- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts`
|
||||
- `googleapis` npm dependency in `package.json`
|
||||
|
||||
- Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface)
|
||||
- Adds `src/channels/gmail.test.ts` (unit tests)
|
||||
- Three-way merges Gmail channel wiring into `src/index.ts` (GmailChannel creation)
|
||||
- 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 merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
### Add email handling instructions (Channel mode only)
|
||||
|
||||
- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
|
||||
- `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
|
||||
|
||||
#### Add email handling instructions
|
||||
|
||||
Append the following to `groups/main/CLAUDE.md` (before the formatting section):
|
||||
If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section):
|
||||
|
||||
```markdown
|
||||
## Email Notifications
|
||||
@@ -91,14 +67,15 @@ Append the following to `groups/main/CLAUDE.md` (before the formatting section):
|
||||
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
|
||||
```
|
||||
|
||||
#### Validate
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/channels/gmail.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the new gmail tests) and build must be clean before proceeding.
|
||||
All tests must pass (including the new Gmail tests) and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
@@ -227,18 +204,17 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp
|
||||
|
||||
1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
|
||||
2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
|
||||
3. Remove `gmail` from `.nanoclaw/state.yaml`
|
||||
3. Rebuild and restart
|
||||
4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
|
||||
5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
|
||||
### 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. Rebuild and restart
|
||||
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)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { GmailChannel, GmailChannelOpts } from './gmail.js';
|
||||
|
||||
function makeOpts(overrides?: Partial<GmailChannelOpts>): GmailChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: () => ({}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GmailChannel', () => {
|
||||
let channel: GmailChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
channel = new GmailChannel(makeOpts());
|
||||
});
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('returns true for gmail: prefixed JIDs', () => {
|
||||
expect(channel.ownsJid('gmail:abc123')).toBe(true);
|
||||
expect(channel.ownsJid('gmail:thread-id-456')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-gmail JIDs', () => {
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||
expect(channel.ownsJid('tg:123')).toBe(false);
|
||||
expect(channel.ownsJid('dc:456')).toBe(false);
|
||||
expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('name', () => {
|
||||
it('is gmail', () => {
|
||||
expect(channel.name).toBe('gmail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConnected', () => {
|
||||
it('returns false before connect', () => {
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('sets connected to false', async () => {
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor options', () => {
|
||||
it('accepts custom poll interval', () => {
|
||||
const ch = new GmailChannel(makeOpts(), 30000);
|
||||
expect(ch.name).toBe('gmail');
|
||||
});
|
||||
|
||||
it('defaults to unread query when no filter configured', () => {
|
||||
const ch = new GmailChannel(makeOpts());
|
||||
const query = (ch as unknown as { buildQuery: () => string }).buildQuery();
|
||||
expect(query).toBe('is:unread category:primary');
|
||||
});
|
||||
|
||||
it('defaults with no options provided', () => {
|
||||
const ch = new GmailChannel(makeOpts());
|
||||
expect(ch.name).toBe('gmail');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { google, gmail_v1 } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
import { MAIN_GROUP_FOLDER } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
Channel,
|
||||
OnChatMetadata,
|
||||
OnInboundMessage,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
export interface GmailChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
interface ThreadMeta {
|
||||
sender: string;
|
||||
senderName: string;
|
||||
subject: string;
|
||||
messageId: string; // RFC 2822 Message-ID for In-Reply-To
|
||||
}
|
||||
|
||||
export class GmailChannel implements Channel {
|
||||
name = 'gmail';
|
||||
|
||||
private oauth2Client: OAuth2Client | null = null;
|
||||
private gmail: gmail_v1.Gmail | null = null;
|
||||
private opts: GmailChannelOpts;
|
||||
private pollIntervalMs: number;
|
||||
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private processedIds = new Set<string>();
|
||||
private threadMeta = new Map<string, ThreadMeta>();
|
||||
private consecutiveErrors = 0;
|
||||
private userEmail = '';
|
||||
|
||||
constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) {
|
||||
this.opts = opts;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const credDir = path.join(os.homedir(), '.gmail-mcp');
|
||||
const keysPath = path.join(credDir, 'gcp-oauth.keys.json');
|
||||
const tokensPath = path.join(credDir, 'credentials.json');
|
||||
|
||||
if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) {
|
||||
logger.warn(
|
||||
'Gmail credentials not found in ~/.gmail-mcp/. Skipping Gmail channel. Run /add-gmail to set up.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8'));
|
||||
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
||||
|
||||
const clientConfig = keys.installed || keys.web || keys;
|
||||
const { client_id, client_secret, redirect_uris } = clientConfig;
|
||||
this.oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0],
|
||||
);
|
||||
this.oauth2Client.setCredentials(tokens);
|
||||
|
||||
// Persist refreshed tokens
|
||||
this.oauth2Client.on('tokens', (newTokens) => {
|
||||
try {
|
||||
const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
||||
Object.assign(current, newTokens);
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2));
|
||||
logger.debug('Gmail OAuth tokens refreshed');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to persist refreshed Gmail tokens');
|
||||
}
|
||||
});
|
||||
|
||||
this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
||||
|
||||
// Verify connection
|
||||
const profile = await this.gmail.users.getProfile({ userId: 'me' });
|
||||
this.userEmail = profile.data.emailAddress || '';
|
||||
logger.info({ email: this.userEmail }, 'Gmail channel connected');
|
||||
|
||||
// Start polling with error backoff
|
||||
const schedulePoll = () => {
|
||||
const backoffMs = this.consecutiveErrors > 0
|
||||
? Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000)
|
||||
: this.pollIntervalMs;
|
||||
this.pollTimer = setTimeout(() => {
|
||||
this.pollForMessages()
|
||||
.catch((err) => logger.error({ err }, 'Gmail poll error'))
|
||||
.finally(() => {
|
||||
if (this.gmail) schedulePoll();
|
||||
});
|
||||
}, backoffMs);
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
await this.pollForMessages();
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
if (!this.gmail) {
|
||||
logger.warn('Gmail not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = jid.replace(/^gmail:/, '');
|
||||
const meta = this.threadMeta.get(threadId);
|
||||
|
||||
if (!meta) {
|
||||
logger.warn({ jid }, 'No thread metadata for reply, cannot send');
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = meta.subject.startsWith('Re:')
|
||||
? meta.subject
|
||||
: `Re: ${meta.subject}`;
|
||||
|
||||
const headers = [
|
||||
`To: ${meta.sender}`,
|
||||
`From: ${this.userEmail}`,
|
||||
`Subject: ${subject}`,
|
||||
`In-Reply-To: ${meta.messageId}`,
|
||||
`References: ${meta.messageId}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
text,
|
||||
].join('\r\n');
|
||||
|
||||
const encodedMessage = Buffer.from(headers)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
try {
|
||||
await this.gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: encodedMessage,
|
||||
threadId,
|
||||
},
|
||||
});
|
||||
logger.info({ to: meta.sender, threadId }, 'Gmail reply sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send Gmail reply');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.gmail !== null;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('gmail:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.gmail = null;
|
||||
this.oauth2Client = null;
|
||||
logger.info('Gmail channel stopped');
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private buildQuery(): string {
|
||||
return 'is:unread category:primary';
|
||||
}
|
||||
|
||||
private async pollForMessages(): Promise<void> {
|
||||
if (!this.gmail) return;
|
||||
|
||||
try {
|
||||
const query = this.buildQuery();
|
||||
const res = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
q: query,
|
||||
maxResults: 10,
|
||||
});
|
||||
|
||||
const messages = res.data.messages || [];
|
||||
|
||||
for (const stub of messages) {
|
||||
if (!stub.id || this.processedIds.has(stub.id)) continue;
|
||||
this.processedIds.add(stub.id);
|
||||
|
||||
await this.processMessage(stub.id);
|
||||
}
|
||||
|
||||
// Cap processed ID set to prevent unbounded growth
|
||||
if (this.processedIds.size > 5000) {
|
||||
const ids = [...this.processedIds];
|
||||
this.processedIds = new Set(ids.slice(ids.length - 2500));
|
||||
}
|
||||
|
||||
this.consecutiveErrors = 0;
|
||||
} catch (err) {
|
||||
this.consecutiveErrors++;
|
||||
const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000);
|
||||
logger.error({ err, consecutiveErrors: this.consecutiveErrors, nextPollMs: backoffMs }, 'Gmail poll failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async processMessage(messageId: string): Promise<void> {
|
||||
if (!this.gmail) return;
|
||||
|
||||
const msg = await this.gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'full',
|
||||
});
|
||||
|
||||
const headers = msg.data.payload?.headers || [];
|
||||
const getHeader = (name: string) =>
|
||||
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())
|
||||
?.value || '';
|
||||
|
||||
const from = getHeader('From');
|
||||
const subject = getHeader('Subject');
|
||||
const rfc2822MessageId = getHeader('Message-ID');
|
||||
const threadId = msg.data.threadId || messageId;
|
||||
const timestamp = new Date(
|
||||
parseInt(msg.data.internalDate || '0', 10),
|
||||
).toISOString();
|
||||
|
||||
// Extract sender name and email
|
||||
const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/);
|
||||
const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from;
|
||||
const senderEmail = senderMatch ? senderMatch[2] : from;
|
||||
|
||||
// Skip emails from self (our own replies)
|
||||
if (senderEmail === this.userEmail) return;
|
||||
|
||||
// Extract body text
|
||||
const body = this.extractTextBody(msg.data.payload);
|
||||
|
||||
if (!body) {
|
||||
logger.debug({ messageId, subject }, 'Skipping email with no text body');
|
||||
return;
|
||||
}
|
||||
|
||||
const chatJid = `gmail:${threadId}`;
|
||||
|
||||
// Cache thread metadata for replies
|
||||
this.threadMeta.set(threadId, {
|
||||
sender: senderEmail,
|
||||
senderName,
|
||||
subject,
|
||||
messageId: rfc2822MessageId,
|
||||
});
|
||||
|
||||
// Store chat metadata for group discovery
|
||||
this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false);
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
if (!mainEntry) {
|
||||
logger.debug(
|
||||
{ chatJid, subject },
|
||||
'No main group registered, skipping email',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mainJid = mainEntry[0];
|
||||
const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`;
|
||||
|
||||
this.opts.onMessage(mainJid, {
|
||||
id: messageId,
|
||||
chat_jid: mainJid,
|
||||
sender: senderEmail,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
// Mark as read
|
||||
try {
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
requestBody: { removeLabelIds: ['UNREAD'] },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ messageId, err }, 'Failed to mark email as read');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ mainJid, from: senderName, subject },
|
||||
'Gmail email delivered to main group',
|
||||
);
|
||||
}
|
||||
|
||||
private extractTextBody(
|
||||
payload: gmail_v1.Schema$MessagePart | undefined,
|
||||
): string {
|
||||
if (!payload) return '';
|
||||
|
||||
// Direct text/plain body
|
||||
if (payload.mimeType === 'text/plain' && payload.body?.data) {
|
||||
return Buffer.from(payload.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
// Multipart: search parts recursively
|
||||
if (payload.parts) {
|
||||
// Prefer text/plain
|
||||
for (const part of payload.parts) {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
return Buffer.from(part.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
}
|
||||
// Recurse into nested multipart
|
||||
for (const part of payload.parts) {
|
||||
const text = this.extractTextBody(part);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
skill: gmail
|
||||
version: 1.0.0
|
||||
description: "Gmail integration via Google APIs"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/channels/gmail.ts
|
||||
- src/channels/gmail.test.ts
|
||||
modifies:
|
||||
- src/index.ts
|
||||
- src/container-runner.ts
|
||||
- container/agent-runner/src/index.ts
|
||||
- src/routing.test.ts
|
||||
structured:
|
||||
npm_dependencies:
|
||||
googleapis: "^144.0.0"
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npx vitest run src/channels/gmail.test.ts"
|
||||
@@ -1,593 +0,0 @@
|
||||
/**
|
||||
* NanoClaw Agent Runner
|
||||
* Runs inside a container, receives config via stdin, outputs result to stdout
|
||||
*
|
||||
* Input protocol:
|
||||
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
||||
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
|
||||
* Files: {type:"message", text:"..."}.json — polled and consumed
|
||||
* Sentinel: /workspace/ipc/input/_close — signals session end
|
||||
*
|
||||
* Stdout protocol:
|
||||
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
||||
* Multiple results may be emitted (one per agent teams result).
|
||||
* Final marker after loop ends signals completion.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
fullPath: string;
|
||||
summary: string;
|
||||
firstPrompt: string;
|
||||
}
|
||||
|
||||
interface SessionsIndex {
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string };
|
||||
parent_tool_use_id: null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||
const IPC_POLL_MS = 500;
|
||||
|
||||
/**
|
||||
* Push-based async iterable for streaming user messages to the SDK.
|
||||
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
|
||||
*/
|
||||
class MessageStream {
|
||||
private queue: SDKUserMessage[] = [];
|
||||
private waiting: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
push(text: string): void {
|
||||
this.queue.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
});
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.done = true;
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
||||
while (true) {
|
||||
while (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
}
|
||||
if (this.done) return;
|
||||
await new Promise<void>(r => { this.waiting = r; });
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { data += chunk; });
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
function writeOutput(output: ContainerOutput): void {
|
||||
console.log(OUTPUT_START_MARKER);
|
||||
console.log(JSON.stringify(output));
|
||||
console.log(OUTPUT_END_MARKER);
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
console.error(`[agent-runner] ${message}`);
|
||||
}
|
||||
|
||||
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
|
||||
const projectDir = path.dirname(transcriptPath);
|
||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
log(`Sessions index not found at ${indexPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
const entry = index.entries.find(e => e.sessionId === sessionId);
|
||||
if (entry?.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the full transcript to conversations/ before compaction.
|
||||
*/
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const transcriptPath = preCompact.transcript_path;
|
||||
const sessionId = preCompact.session_id;
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
|
||||
if (messages.length === 0) {
|
||||
log('No messages to archive');
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary = getSessionSummary(sessionId, transcriptPath);
|
||||
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
||||
|
||||
const conversationsDir = '/workspace/group/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const filename = `${date}-${name}.md`;
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
// Secrets to strip from Bash tool subprocess environments.
|
||||
// These are needed by claude-code for API auth but should never
|
||||
// be visible to commands Kit runs.
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function generateFallbackName(): string {
|
||||
const time = new Date();
|
||||
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseTranscript(content: string): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text = typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const textParts = entry.message.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text);
|
||||
const text = textParts.join('');
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
||||
const now = new Date();
|
||||
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title || 'Conversation'}`);
|
||||
lines.push('');
|
||||
lines.push(`Archived: ${formatDateTime(now)}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
|
||||
const content = msg.content.length > 2000
|
||||
? msg.content.slice(0, 2000) + '...'
|
||||
: msg.content;
|
||||
lines.push(`**${sender}**: ${content}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for _close sentinel.
|
||||
*/
|
||||
function shouldClose(): boolean {
|
||||
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all pending IPC input messages.
|
||||
* Returns messages found, or empty array.
|
||||
*/
|
||||
function drainIpcInput(): string[] {
|
||||
try {
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
const files = fs.readdirSync(IPC_INPUT_DIR)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
const messages: string[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(IPC_INPUT_DIR, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
fs.unlinkSync(filePath);
|
||||
if (data.type === 'message' && data.text) {
|
||||
messages.push(data.text);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
} catch (err) {
|
||||
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a new IPC message or _close sentinel.
|
||||
* Returns the messages as a single string, or null if _close.
|
||||
*/
|
||||
function waitForIpcMessage(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const poll = () => {
|
||||
if (shouldClose()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const messages = drainIpcInput();
|
||||
if (messages.length > 0) {
|
||||
resolve(messages.join('\n'));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, IPC_POLL_MS);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single query and stream results via writeOutput.
|
||||
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
|
||||
* allowing agent teams subagents to run to completion.
|
||||
* Also pipes IPC messages into the stream during the query.
|
||||
*/
|
||||
async function runQuery(
|
||||
prompt: string,
|
||||
sessionId: string | undefined,
|
||||
mcpServerPath: string,
|
||||
containerInput: ContainerInput,
|
||||
sdkEnv: Record<string, string | undefined>,
|
||||
resumeAt?: string,
|
||||
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
|
||||
const stream = new MessageStream();
|
||||
stream.push(prompt);
|
||||
|
||||
// Poll IPC for follow-up messages and _close sentinel during the query
|
||||
let ipcPolling = true;
|
||||
let closedDuringQuery = false;
|
||||
const pollIpcDuringQuery = () => {
|
||||
if (!ipcPolling) return;
|
||||
if (shouldClose()) {
|
||||
log('Close sentinel detected during query, ending stream');
|
||||
closedDuringQuery = true;
|
||||
stream.end();
|
||||
ipcPolling = false;
|
||||
return;
|
||||
}
|
||||
const messages = drainIpcInput();
|
||||
for (const text of messages) {
|
||||
log(`Piping IPC message into active query (${text.length} chars)`);
|
||||
stream.push(text);
|
||||
}
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
};
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
|
||||
let newSessionId: string | undefined;
|
||||
let lastAssistantUuid: string | undefined;
|
||||
let messageCount = 0;
|
||||
let resultCount = 0;
|
||||
|
||||
// Load global CLAUDE.md as additional system context (shared across all groups)
|
||||
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
|
||||
let globalClaudeMd: string | undefined;
|
||||
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
||||
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Discover additional directories mounted at /workspace/extra/*
|
||||
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
|
||||
const extraDirs: string[] = [];
|
||||
const extraBase = '/workspace/extra';
|
||||
if (fs.existsSync(extraBase)) {
|
||||
for (const entry of fs.readdirSync(extraBase)) {
|
||||
const fullPath = path.join(extraBase, entry);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
extraDirs.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraDirs.length > 0) {
|
||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||
}
|
||||
|
||||
for await (const message of query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||
resume: sessionId,
|
||||
resumeSessionAt: resumeAt,
|
||||
systemPrompt: globalClaudeMd
|
||||
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
|
||||
: undefined,
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch',
|
||||
'Task', 'TaskOutput', 'TaskStop',
|
||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||
'TodoWrite', 'ToolSearch', 'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__gmail__*',
|
||||
],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
NANOCLAW_CHAT_JID: containerInput.chatJid,
|
||||
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
gmail: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
}
|
||||
})) {
|
||||
messageCount++;
|
||||
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
|
||||
log(`[msg #${messageCount}] type=${msgType}`);
|
||||
|
||||
if (message.type === 'assistant' && 'uuid' in message) {
|
||||
lastAssistantUuid = (message as { uuid: string }).uuid;
|
||||
}
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
newSessionId = message.session_id;
|
||||
log(`Session initialized: ${newSessionId}`);
|
||||
}
|
||||
|
||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { task_id: string; status: string; summary: string };
|
||||
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
newSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcPolling = false;
|
||||
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
|
||||
return { newSessionId, lastAssistantUuid, closedDuringQuery };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let containerInput: ContainerInput;
|
||||
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build SDK env: merge secrets into process.env for the SDK only.
|
||||
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
|
||||
let sessionId = containerInput.sessionId;
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
|
||||
// Clean up stale _close sentinel from previous container runs
|
||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
||||
|
||||
// Build initial prompt (drain any pending IPC messages too)
|
||||
let prompt = containerInput.prompt;
|
||||
if (containerInput.isScheduledTask) {
|
||||
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
|
||||
}
|
||||
const pending = drainIpcInput();
|
||||
if (pending.length > 0) {
|
||||
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
||||
prompt += '\n' + pending.join('\n');
|
||||
}
|
||||
|
||||
// Query loop: run query → wait for IPC message → run new query → repeat
|
||||
let resumeAt: string | undefined;
|
||||
try {
|
||||
while (true) {
|
||||
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
|
||||
|
||||
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
|
||||
if (queryResult.newSessionId) {
|
||||
sessionId = queryResult.newSessionId;
|
||||
}
|
||||
if (queryResult.lastAssistantUuid) {
|
||||
resumeAt = queryResult.lastAssistantUuid;
|
||||
}
|
||||
|
||||
// If _close was consumed during the query, exit immediately.
|
||||
// Don't emit a session-update marker (it would reset the host's
|
||||
// idle timer and cause a 30-min delay before the next _close).
|
||||
if (queryResult.closedDuringQuery) {
|
||||
log('Close sentinel consumed during query, exiting');
|
||||
break;
|
||||
}
|
||||
|
||||
// Emit session update so host can track it
|
||||
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
|
||||
|
||||
log('Query ended, waiting for next IPC message...');
|
||||
|
||||
// Wait for the next message or _close sentinel
|
||||
const nextMessage = await waitForIpcMessage();
|
||||
if (nextMessage === null) {
|
||||
log('Close sentinel received, exiting');
|
||||
break;
|
||||
}
|
||||
|
||||
log(`Got new message (${nextMessage.length} chars), starting new query`);
|
||||
prompt = nextMessage;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log(`Agent error: ${errorMessage}`);
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
newSessionId: sessionId,
|
||||
error: errorMessage
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,32 +0,0 @@
|
||||
# Intent: container/agent-runner/src/index.ts modifications
|
||||
|
||||
## What changed
|
||||
Added Gmail MCP server to the agent's available tools so it can read and send emails.
|
||||
|
||||
## Key sections
|
||||
|
||||
### mcpServers (inside runQuery → query() call)
|
||||
- Added: `gmail` MCP server alongside the existing `nanoclaw` server:
|
||||
```
|
||||
gmail: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
|
||||
},
|
||||
```
|
||||
|
||||
### allowedTools (inside runQuery → query() call)
|
||||
- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools
|
||||
|
||||
## Invariants
|
||||
- The `nanoclaw` MCP server configuration is unchanged
|
||||
- All existing allowed tools are preserved
|
||||
- The query loop, IPC handling, MessageStream, and all other logic is untouched
|
||||
- Hooks (PreCompact, sanitize Bash) are unchanged
|
||||
- Output protocol (markers) is unchanged
|
||||
|
||||
## Must-keep
|
||||
- The `nanoclaw` MCP server with its environment variables
|
||||
- All existing allowedTools entries
|
||||
- The hook system (PreCompact, PreToolUse sanitize)
|
||||
- The IPC input/close sentinel handling
|
||||
- The MessageStream class and query loop
|
||||
@@ -1,661 +0,0 @@
|
||||
/**
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in containers and handles IPC
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
export interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VolumeMount {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
function buildVolumeMounts(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const projectRoot = process.cwd();
|
||||
const homeDir = os.homedir();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory (read-only for non-main)
|
||||
// Only directory mounts are supported, not file mounts
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-group Claude sessions directory (isolated from other groups)
|
||||
// Each group gets their own .claude/ to prevent cross-group session access
|
||||
const groupSessionsDir = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'.claude',
|
||||
);
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
env: {
|
||||
// Enable agent swarms (subagent orchestration)
|
||||
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
// Load CLAUDE.md from additional mounted directories
|
||||
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
// Enable Claude's memory feature (persists user preferences between sessions)
|
||||
// https://code.claude.com/docs/en/memory#manage-auto-memory
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// Sync skills from container/skills/ into each group's .claude/skills/
|
||||
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
|
||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
for (const skillDir of fs.readdirSync(skillsSrc)) {
|
||||
const srcDir = path.join(skillsSrc, skillDir);
|
||||
if (!fs.statSync(srcDir).isDirectory()) continue;
|
||||
const dstDir = path.join(skillsDst, skillDir);
|
||||
fs.cpSync(srcDir, dstDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupSessionsDir,
|
||||
containerPath: '/home/node/.claude',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Gmail credentials directory (for Gmail MCP inside the container)
|
||||
const gmailDir = path.join(homeDir, '.gmail-mcp');
|
||||
if (fs.existsSync(gmailDir)) {
|
||||
mounts.push({
|
||||
hostPath: gmailDir,
|
||||
containerPath: '/home/node/.gmail-mcp',
|
||||
readonly: false, // MCP may need to refresh OAuth tokens
|
||||
});
|
||||
}
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
// This prevents cross-group privilege escalation via IPC
|
||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||
mounts.push({
|
||||
hostPath: groupIpcDir,
|
||||
containerPath: '/workspace/ipc',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Copy agent-runner source into a per-group writable location so agents
|
||||
// can customize it (add tools, change behavior) without affecting other
|
||||
// groups. Recompiled on container startup via entrypoint.sh.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupAgentRunnerDir,
|
||||
containerPath: '/app/src',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
||||
if (group.containerConfig?.additionalMounts) {
|
||||
const validatedMounts = validateAdditionalMounts(
|
||||
group.containerConfig.additionalMounts,
|
||||
group.name,
|
||||
isMain,
|
||||
);
|
||||
mounts.push(...validatedMounts);
|
||||
}
|
||||
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read allowed secrets from .env for passing to the container via stdin.
|
||||
* Secrets are never written to disk or mounted as files.
|
||||
*/
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
|
||||
}
|
||||
|
||||
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
const hostUid = process.getuid?.();
|
||||
const hostGid = process.getgid?.();
|
||||
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
||||
args.push('--user', `${hostUid}:${hostGid}`);
|
||||
args.push('-e', 'HOME=/home/node');
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.readonly) {
|
||||
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
|
||||
} else {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(CONTAINER_IMAGE);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function runContainerAgent(
|
||||
group: RegisteredGroup,
|
||||
input: ContainerInput,
|
||||
onProcess: (proc: ChildProcess, containerName: string) => void,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||
const containerArgs = buildContainerArgs(mounts, containerName);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mounts: mounts.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
),
|
||||
containerArgs: containerArgs.join(' '),
|
||||
},
|
||||
'Container mount configuration',
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mountCount: mounts.length,
|
||||
isMain: input.isMain,
|
||||
},
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(groupDir, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
onProcess(container, containerName);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||
input.secrets = readSecrets();
|
||||
container.stdin.write(JSON.stringify(input));
|
||||
container.stdin.end();
|
||||
// Remove secrets from input so they don't appear in logs
|
||||
delete input.secrets;
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||
let parseBuffer = '';
|
||||
let newSessionId: string | undefined;
|
||||
let outputChain = Promise.resolve();
|
||||
|
||||
container.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
|
||||
// Always accumulate for logging
|
||||
if (!stdoutTruncated) {
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
||||
if (chunk.length > remaining) {
|
||||
stdout += chunk.slice(0, remaining);
|
||||
stdoutTruncated = true;
|
||||
logger.warn(
|
||||
{ group: group.name, size: stdout.length },
|
||||
'Container stdout truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stdout += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream-parse for output markers
|
||||
if (onOutput) {
|
||||
parseBuffer += chunk;
|
||||
let startIdx: number;
|
||||
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
|
||||
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
|
||||
if (endIdx === -1) break; // Incomplete pair, wait for more data
|
||||
|
||||
const jsonStr = parseBuffer
|
||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||
.trim();
|
||||
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
|
||||
|
||||
try {
|
||||
const parsed: ContainerOutput = JSON.parse(jsonStr);
|
||||
if (parsed.newSessionId) {
|
||||
newSessionId = parsed.newSessionId;
|
||||
}
|
||||
hadStreamingOutput = true;
|
||||
// Activity detected — reset the hard timeout
|
||||
resetTimeout();
|
||||
// Call onOutput for all markers (including null results)
|
||||
// so idle timers start even for "silent" query completions.
|
||||
outputChain = outputChain.then(() => onOutput(parsed));
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, error: err },
|
||||
'Failed to parse streamed output chunk',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ container: group.folder }, line);
|
||||
}
|
||||
// Don't reset timeout on stderr — SDK writes debug logs continuously.
|
||||
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
|
||||
if (stderrTruncated) return;
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
logger.warn(
|
||||
{ group: group.name, size: stderr.length },
|
||||
'Container stderr truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
||||
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
||||
|
||||
const killOnTimeout = () => {
|
||||
timedOut = true;
|
||||
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
|
||||
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
|
||||
// Reset the timeout whenever there's activity (streaming output)
|
||||
const resetTimeout = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
};
|
||||
|
||||
container.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
||||
fs.writeFileSync(timeoutLog, [
|
||||
`=== Container Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`Container: ${containerName}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||
].join('\n'));
|
||||
|
||||
// Timeout after output = idle cleanup, not failure.
|
||||
// The agent already sent its response; this is just the
|
||||
// container being reaped after the idle period expired.
|
||||
if (hadStreamingOutput) {
|
||||
logger.info(
|
||||
{ group: group.name, containerName, duration, code },
|
||||
'Container timed out after output (idle cleanup)',
|
||||
);
|
||||
outputChain.then(() => {
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ group: group.name, containerName, duration, code },
|
||||
'Container timed out with no output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container timed out after ${configTimeout}ms`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
||||
const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
|
||||
const logLines = [
|
||||
`=== Container Run Log ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`IsMain: ${input.isMain}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Stdout Truncated: ${stdoutTruncated}`,
|
||||
`Stderr Truncated: ${stderrTruncated}`,
|
||||
``,
|
||||
];
|
||||
|
||||
const isError = code !== 0;
|
||||
|
||||
if (isVerbose || isError) {
|
||||
logLines.push(
|
||||
`=== Input ===`,
|
||||
JSON.stringify(input, null, 2),
|
||||
``,
|
||||
`=== Container Args ===`,
|
||||
containerArgs.join(' '),
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
)
|
||||
.join('\n'),
|
||||
``,
|
||||
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stderr,
|
||||
``,
|
||||
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stdout,
|
||||
);
|
||||
} else {
|
||||
logLines.push(
|
||||
`=== Input Summary ===`,
|
||||
`Prompt length: ${input.prompt.length} chars`,
|
||||
`Session ID: ${input.sessionId || 'new'}`,
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts
|
||||
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
|
||||
.join('\n'),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(logFile, logLines.join('\n'));
|
||||
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
code,
|
||||
duration,
|
||||
stderr,
|
||||
stdout,
|
||||
logFile,
|
||||
},
|
||||
'Container exited with error',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming mode: wait for output chain to settle, return completion marker
|
||||
if (onOutput) {
|
||||
outputChain.then(() => {
|
||||
logger.info(
|
||||
{ group: group.name, duration, newSessionId },
|
||||
'Container completed (streaming mode)',
|
||||
);
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: parse the last output marker pair from accumulated stdout
|
||||
try {
|
||||
// Extract JSON between sentinel markers for robust parsing
|
||||
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
||||
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
||||
|
||||
let jsonLine: string;
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
jsonLine = stdout
|
||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||
.trim();
|
||||
} else {
|
||||
// Fallback: last non-empty line (backwards compatibility)
|
||||
const lines = stdout.trim().split('\n');
|
||||
jsonLine = lines[lines.length - 1];
|
||||
}
|
||||
|
||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
duration,
|
||||
status: output.status,
|
||||
hasResult: !!output.result,
|
||||
},
|
||||
'Container completed',
|
||||
);
|
||||
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
stdout,
|
||||
stderr,
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse container output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
container.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function writeTasksSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string | null;
|
||||
}>,
|
||||
): void {
|
||||
// Write filtered tasks to the group's IPC directory
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all tasks, others only see their own
|
||||
const filteredTasks = isMain
|
||||
? tasks
|
||||
: tasks.filter((t) => t.groupFolder === groupFolder);
|
||||
|
||||
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
||||
}
|
||||
|
||||
export interface AvailableGroup {
|
||||
jid: string;
|
||||
name: string;
|
||||
lastActivity: string;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write available groups snapshot for the container to read.
|
||||
* Only main group can see all available groups (for activation).
|
||||
* Non-main groups only see their own registration status.
|
||||
*/
|
||||
export function writeGroupsSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all groups; others see nothing (they can't activate groups)
|
||||
const visibleGroups = isMain ? groups : [];
|
||||
|
||||
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
||||
fs.writeFileSync(
|
||||
groupsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
groups: visibleGroups,
|
||||
lastSync: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
# Intent: src/container-runner.ts modifications
|
||||
|
||||
## What changed
|
||||
Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google.
|
||||
|
||||
## Key sections
|
||||
|
||||
### buildVolumeMounts()
|
||||
- Added: Gmail credentials mount after the `.claude` sessions mount:
|
||||
```
|
||||
const gmailDir = path.join(homeDir, '.gmail-mcp');
|
||||
if (fs.existsSync(gmailDir)) {
|
||||
mounts.push({
|
||||
hostPath: gmailDir,
|
||||
containerPath: '/home/node/.gmail-mcp',
|
||||
readonly: false, // MCP may need to refresh OAuth tokens
|
||||
});
|
||||
}
|
||||
```
|
||||
- Uses `os.homedir()` to resolve the home directory
|
||||
- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens
|
||||
- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host
|
||||
|
||||
### Imports
|
||||
- Added: `os` import for `os.homedir()`
|
||||
|
||||
## Invariants
|
||||
- All existing mounts are unchanged
|
||||
- Mount ordering is preserved (Gmail added after session mounts, before additional mounts)
|
||||
- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched
|
||||
- Additional mount validation via `validateAdditionalMounts` is unchanged
|
||||
|
||||
## Must-keep
|
||||
- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional)
|
||||
- The mount security model (allowlist validation for additional mounts)
|
||||
- The `readSecrets` function and stdin-based secret passing
|
||||
- Container lifecycle (spawn, timeout, output parsing)
|
||||
@@ -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<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
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<string, RegisteredGroup>): 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<boolean> {
|
||||
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<typeof setTimeout> | 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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<void>,
|
||||
): 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<void> {
|
||||
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<string, NewMessage[]>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
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');
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('agent-runner has gmail MCP server', () => {
|
||||
const content = read('container/agent-runner/src/index.ts');
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
94
.claude/skills/add-image-vision/SKILL.md
Normal file
94
.claude/skills/add-image-vision/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: add-image-vision
|
||||
description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
|
||||
---
|
||||
|
||||
# Image Vision Skill
|
||||
|
||||
Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
1. Check if `src/image.ts` exists — skip to Phase 3 if already applied
|
||||
2. Confirm `sharp` is installable (native bindings require build tools)
|
||||
|
||||
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/image-vision
|
||||
git merge whatsapp/skill/image-vision || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/image.ts` (image download, resize via sharp, base64 encoding)
|
||||
- `src/image.test.ts` (8 unit tests)
|
||||
- Image attachment handling in `src/channels/whatsapp.ts`
|
||||
- Image passing to agent in `src/index.ts` and `src/container-runner.ts`
|
||||
- Image content block support in `container/agent-runner/src/index.ts`
|
||||
- `sharp` npm dependency in `package.json`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/image.test.ts
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
1. Rebuild the container (agent-runner changes need a rebuild):
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
2. Sync agent-runner source to group caches:
|
||||
```bash
|
||||
for dir in data/sessions/*/agent-runner-src/; do
|
||||
cp container/agent-runner/src/*.ts "$dir"
|
||||
done
|
||||
```
|
||||
|
||||
3. Restart the service:
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
1. Send an image in a registered WhatsApp group
|
||||
2. Check the agent responds with understanding of the image content
|
||||
3. Check logs for "Processed image attachment":
|
||||
```bash
|
||||
tail -50 groups/*/logs/container-*.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections.
|
||||
- **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify.
|
||||
- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches.
|
||||
133
.claude/skills/add-macos-statusbar/SKILL.md
Normal file
133
.claude/skills/add-macos-statusbar/SKILL.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: add-macos-statusbar
|
||||
description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only.
|
||||
---
|
||||
|
||||
# Add macOS Menu Bar Status Indicator
|
||||
|
||||
Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user
|
||||
start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar.
|
||||
|
||||
**macOS only.** Requires Xcode Command Line Tools (`swiftc`).
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check platform
|
||||
|
||||
If not on macOS, stop and tell the user:
|
||||
|
||||
> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools).
|
||||
|
||||
### Check for swiftc
|
||||
|
||||
```bash
|
||||
which swiftc
|
||||
```
|
||||
|
||||
If not found, tell the user:
|
||||
|
||||
> Xcode Command Line Tools are required. Install them by running:
|
||||
>
|
||||
> ```bash
|
||||
> xcode-select --install
|
||||
> ```
|
||||
>
|
||||
> Then re-run `/add-macos-statusbar`.
|
||||
|
||||
### Check if already installed
|
||||
|
||||
```bash
|
||||
launchctl list | grep com.nanoclaw.statusbar
|
||||
```
|
||||
|
||||
If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Compile and Install
|
||||
|
||||
### Compile the Swift binary
|
||||
|
||||
The source lives in the skill directory. Compile it into `dist/`:
|
||||
|
||||
```bash
|
||||
mkdir -p dist
|
||||
swiftc -O -o dist/statusbar "${CLAUDE_SKILL_DIR}/add/src/statusbar.swift"
|
||||
```
|
||||
|
||||
This produces a small native binary at `dist/statusbar`.
|
||||
|
||||
On macOS Sequoia or later, clear the quarantine attribute so the binary can run:
|
||||
|
||||
```bash
|
||||
xattr -cr dist/statusbar
|
||||
```
|
||||
|
||||
### Create the launchd plist
|
||||
|
||||
Determine the absolute project root and home directory:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
echo $HOME
|
||||
```
|
||||
|
||||
Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values
|
||||
for `{PROJECT_ROOT}` and `{HOME}`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw.statusbar</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{PROJECT_ROOT}/dist/statusbar</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>{HOME}</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{PROJECT_ROOT}/logs/statusbar.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{PROJECT_ROOT}/logs/statusbar.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Load the service
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
```bash
|
||||
launchctl list | grep com.nanoclaw.statusbar
|
||||
```
|
||||
|
||||
The first column should show a PID (not `-`).
|
||||
|
||||
Tell the user:
|
||||
|
||||
> The bolt icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service.
|
||||
>
|
||||
> - **Green dot** — NanoClaw is running
|
||||
> - **Red dot** — NanoClaw is stopped
|
||||
>
|
||||
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
|
||||
|
||||
## Removal
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
|
||||
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
|
||||
rm dist/statusbar
|
||||
```
|
||||
147
.claude/skills/add-macos-statusbar/add/src/statusbar.swift
Normal file
147
.claude/skills/add-macos-statusbar/add/src/statusbar.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import AppKit
|
||||
|
||||
class StatusBarController: NSObject {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var isRunning = false
|
||||
private var timer: Timer?
|
||||
|
||||
private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist"
|
||||
|
||||
/// Derive the NanoClaw project root from the binary location.
|
||||
/// The binary is compiled to {project}/dist/statusbar, so the parent of
|
||||
/// the parent directory is the project root.
|
||||
private static let projectRoot: String = {
|
||||
let binary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
|
||||
return binary.deletingLastPathComponent().deletingLastPathComponent().path
|
||||
}()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupStatusItem()
|
||||
isRunning = checkRunning()
|
||||
updateMenu()
|
||||
// Poll every 5 seconds to reflect external state changes
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let current = self.checkRunning()
|
||||
if current != self.isRunning {
|
||||
self.isRunning = current
|
||||
self.updateMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupStatusItem() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = statusItem.button {
|
||||
if let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "NanoClaw") {
|
||||
image.isTemplate = true
|
||||
button.image = image
|
||||
} else {
|
||||
button.title = "⚡"
|
||||
}
|
||||
button.toolTip = "NanoClaw"
|
||||
}
|
||||
}
|
||||
|
||||
private func checkRunning() -> Bool {
|
||||
let task = Process()
|
||||
task.launchPath = "/bin/launchctl"
|
||||
task.arguments = ["list", "com.nanoclaw"]
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.standardError = Pipe()
|
||||
guard (try? task.run()) != nil else { return false }
|
||||
task.waitUntilExit()
|
||||
if task.terminationStatus != 0 { return false }
|
||||
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
// launchctl list output: "PID\tExitCode\tLabel" — "-" means not running
|
||||
let pid = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t").first ?? "-"
|
||||
return pid != "-"
|
||||
}
|
||||
|
||||
private func updateMenu() {
|
||||
let menu = NSMenu()
|
||||
|
||||
// Status row with colored dot
|
||||
let statusItem = NSMenuItem()
|
||||
let dot = "● "
|
||||
let dotColor: NSColor = isRunning ? .systemGreen : .systemRed
|
||||
let attr = NSMutableAttributedString(string: dot, attributes: [.foregroundColor: dotColor])
|
||||
let label = isRunning ? "NanoClaw is running" : "NanoClaw is stopped"
|
||||
attr.append(NSAttributedString(string: label, attributes: [.foregroundColor: NSColor.labelColor]))
|
||||
statusItem.attributedTitle = attr
|
||||
statusItem.isEnabled = false
|
||||
menu.addItem(statusItem)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
if isRunning {
|
||||
let stop = NSMenuItem(title: "Stop", action: #selector(stopService), keyEquivalent: "")
|
||||
stop.target = self
|
||||
menu.addItem(stop)
|
||||
|
||||
let restart = NSMenuItem(title: "Restart", action: #selector(restartService), keyEquivalent: "r")
|
||||
restart.target = self
|
||||
menu.addItem(restart)
|
||||
} else {
|
||||
let start = NSMenuItem(title: "Start", action: #selector(startService), keyEquivalent: "")
|
||||
start.target = self
|
||||
menu.addItem(start)
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let logs = NSMenuItem(title: "View Logs", action: #selector(viewLogs), keyEquivalent: "")
|
||||
logs.target = self
|
||||
menu.addItem(logs)
|
||||
|
||||
self.statusItem.menu = menu
|
||||
}
|
||||
|
||||
@objc private func startService() {
|
||||
run("/bin/launchctl", ["load", plistPath])
|
||||
refresh(after: 2)
|
||||
}
|
||||
|
||||
@objc private func stopService() {
|
||||
run("/bin/launchctl", ["unload", plistPath])
|
||||
refresh(after: 2)
|
||||
}
|
||||
|
||||
@objc private func restartService() {
|
||||
let uid = getuid()
|
||||
run("/bin/launchctl", ["kickstart", "-k", "gui/\(uid)/com.nanoclaw"])
|
||||
refresh(after: 3)
|
||||
}
|
||||
|
||||
@objc private func viewLogs() {
|
||||
let logPath = "\(StatusBarController.projectRoot)/logs/nanoclaw.log"
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: logPath))
|
||||
}
|
||||
|
||||
private func refresh(after seconds: Double) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isRunning = self.checkRunning()
|
||||
self.updateMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func run(_ path: String, _ args: [String]) -> Int32 {
|
||||
let task = Process()
|
||||
task.launchPath = path
|
||||
task.arguments = args
|
||||
task.standardOutput = Pipe()
|
||||
task.standardError = Pipe()
|
||||
try? task.run()
|
||||
task.waitUntilExit()
|
||||
return task.terminationStatus
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
let controller = StatusBarController()
|
||||
app.run()
|
||||
193
.claude/skills/add-ollama-tool/SKILL.md
Normal file
193
.claude/skills/add-ollama-tool/SKILL.md
Normal file
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: add-ollama-tool
|
||||
description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library.
|
||||
---
|
||||
|
||||
# Add Ollama Integration
|
||||
|
||||
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
|
||||
|
||||
Core tools (always available):
|
||||
- `ollama_list_models` — list installed Ollama models with name, size, and family
|
||||
- `ollama_generate` — send a prompt to a specified model and return the response
|
||||
|
||||
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
|
||||
- `ollama_pull_model` — pull (download) a model from the Ollama registry
|
||||
- `ollama_delete_model` — delete a locally installed model to free disk space
|
||||
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
|
||||
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
|
||||
|
||||
### Check prerequisites
|
||||
|
||||
Verify Ollama is installed and running on the host:
|
||||
|
||||
```bash
|
||||
ollama list
|
||||
```
|
||||
|
||||
If Ollama is not installed, direct the user to https://ollama.com/download.
|
||||
|
||||
If no models are installed, suggest pulling one:
|
||||
|
||||
> You need at least one model. I recommend:
|
||||
>
|
||||
> ```bash
|
||||
> ollama pull gemma3:1b # Small, fast (1GB)
|
||||
> ollama pull llama3.2 # Good general purpose (2GB)
|
||||
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
|
||||
> ```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/ollama-tool
|
||||
git merge upstream/skill/ollama-tool
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
|
||||
- `scripts/ollama-watch.sh` (macOS notification watcher)
|
||||
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
|
||||
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
|
||||
- `OLLAMA_HOST` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Copy to per-group agent-runner
|
||||
|
||||
Existing groups have a cached copy of the agent-runner source. Copy the new files:
|
||||
|
||||
```bash
|
||||
for dir in data/sessions/*/agent-runner-src; do
|
||||
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
|
||||
cp container/agent-runner/src/index.ts "$dir/"
|
||||
done
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Enable model management tools (optional)
|
||||
|
||||
Ask the user:
|
||||
|
||||
> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)?
|
||||
>
|
||||
> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory
|
||||
> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host)
|
||||
|
||||
If the user wants management tools, add to `.env`:
|
||||
|
||||
```bash
|
||||
OLLAMA_ADMIN_TOOLS=true
|
||||
```
|
||||
|
||||
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
|
||||
|
||||
### Set Ollama host (optional)
|
||||
|
||||
By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=http://your-ollama-host:11434
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test inference
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a message like: "use ollama to tell me the capital of France"
|
||||
>
|
||||
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
|
||||
|
||||
### Test model management (if enabled)
|
||||
|
||||
If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
|
||||
|
||||
> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?"
|
||||
>
|
||||
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
|
||||
|
||||
### Monitor activity (optional)
|
||||
|
||||
Run the watcher script for macOS notifications when Ollama is used:
|
||||
|
||||
```bash
|
||||
./scripts/ollama-watch.sh
|
||||
```
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i ollama
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `[OLLAMA] >>> Generating` — generation started
|
||||
- `[OLLAMA] <<< Done` — generation completed
|
||||
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
|
||||
- `[OLLAMA] Deleted:` — model removed (management tools)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says "Ollama is not installed"
|
||||
|
||||
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
|
||||
2. The per-group source wasn't updated — re-copy files (see Phase 2)
|
||||
3. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Ollama"
|
||||
|
||||
1. Verify Ollama is running: `ollama list`
|
||||
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
|
||||
3. If using a custom host, check `OLLAMA_HOST` in `.env`
|
||||
|
||||
### Agent doesn't use Ollama tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
|
||||
|
||||
### `ollama_pull_model` times out on large models
|
||||
|
||||
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
|
||||
|
||||
### Management tools not showing up
|
||||
|
||||
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
|
||||
104
.claude/skills/add-pdf-reader/SKILL.md
Normal file
104
.claude/skills/add-pdf-reader/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: add-pdf-reader
|
||||
description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files.
|
||||
---
|
||||
|
||||
# Add PDF Reader
|
||||
|
||||
Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied
|
||||
2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/pdf-reader
|
||||
git merge whatsapp/skill/pdf-reader || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation)
|
||||
- `container/skills/pdf-reader/pdf-reader` (CLI script)
|
||||
- `poppler-utils` in `container/Dockerfile`
|
||||
- PDF attachment download in `src/channels/whatsapp.ts`
|
||||
- PDF tests in `src/channels/whatsapp.test.ts`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx vitest run src/channels/whatsapp.test.ts
|
||||
```
|
||||
|
||||
### Rebuild container
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Test PDF extraction
|
||||
|
||||
Send a PDF file in any registered WhatsApp chat. The agent should:
|
||||
1. Download the PDF to `attachments/`
|
||||
2. Respond acknowledging the PDF
|
||||
3. Be able to extract text when asked
|
||||
|
||||
### Test URL fetching
|
||||
|
||||
Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch <url>`.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i pdf
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `Downloaded PDF attachment` — successful download
|
||||
- `Failed to download PDF attachment` — media download issue
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says pdf-reader command not found
|
||||
|
||||
Container needs rebuilding. Run `./container/build.sh` and restart the service.
|
||||
|
||||
### PDF text extraction is empty
|
||||
|
||||
The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead.
|
||||
|
||||
### WhatsApp PDF not detected
|
||||
|
||||
Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype.
|
||||
117
.claude/skills/add-reactions/SKILL.md
Normal file
117
.claude/skills/add-reactions/SKILL.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: add-reactions
|
||||
description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.
|
||||
---
|
||||
|
||||
# Add Reactions
|
||||
|
||||
This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/status-tracker.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/reactions
|
||||
git merge whatsapp/skill/reactions || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This adds:
|
||||
- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes)
|
||||
- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry)
|
||||
- `src/status-tracker.test.ts` (unit tests for StatusTracker)
|
||||
- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool)
|
||||
- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts`
|
||||
|
||||
### Run database migration
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrate-reactions.ts
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Build and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Linux:
|
||||
```bash
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
### Test receiving reactions
|
||||
|
||||
1. Send a message from your phone
|
||||
2. React to it with an emoji on WhatsApp
|
||||
3. Check the database:
|
||||
|
||||
```bash
|
||||
sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
### Test sending reactions
|
||||
|
||||
Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Reactions not appearing in database
|
||||
|
||||
- Check NanoClaw logs for `Failed to process reaction` errors
|
||||
- Verify the chat is registered
|
||||
- Confirm the service is running
|
||||
|
||||
### Migration fails
|
||||
|
||||
- Ensure `store/messages.db` exists and is accessible
|
||||
- If "table reactions already exists", the migration already ran — skip it
|
||||
|
||||
### Agent can't send reactions
|
||||
|
||||
- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat
|
||||
- Verify WhatsApp is connected: check logs for connection status
|
||||
@@ -5,65 +5,61 @@ description: Add Slack as a channel. Can replace WhatsApp entirely or run alongs
|
||||
|
||||
# Add Slack Channel
|
||||
|
||||
This skill adds Slack support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
|
||||
This skill adds Slack support to NanoClaw, then walks through interactive setup.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### 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
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
|
||||
|
||||
### Apply the skill
|
||||
If `slack` is missing, add it:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-slack
|
||||
git remote add slack https://github.com/qwibitai/nanoclaw-slack.git
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `src/channels/slack.ts` (SlackChannel class implementing Channel interface)
|
||||
- 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`
|
||||
- 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`
|
||||
### Merge the skill branch
|
||||
|
||||
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
|
||||
```bash
|
||||
git fetch slack main
|
||||
git merge slack/main || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/slack.test.ts` (46 unit tests)
|
||||
- `import './slack.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- `@slack/bolt` npm dependency in `package.json`
|
||||
- `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/channels/slack.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the new slack tests) and build must be clean before proceeding.
|
||||
All tests must pass (including the new Slack tests) and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
@@ -89,11 +85,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:
|
||||
|
||||
@@ -126,30 +118,18 @@ Wait for the user to provide the channel ID.
|
||||
|
||||
### Register the channel
|
||||
|
||||
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
|
||||
The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
|
||||
|
||||
For a main channel (responds to all messages, uses the `main` folder):
|
||||
For a main channel (responds to all messages):
|
||||
|
||||
```typescript
|
||||
registerGroup("slack:<channel-id>", {
|
||||
name: "<channel-name>",
|
||||
folder: "main",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main
|
||||
```
|
||||
|
||||
For additional channels (trigger-only):
|
||||
|
||||
```typescript
|
||||
registerGroup("slack:<channel-id>", {
|
||||
name: "<channel-name>",
|
||||
folder: "<folder-name>",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: true,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel slack
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
@@ -215,7 +195,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
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# Slack App Setup for NanoClaw
|
||||
|
||||
Step-by-step guide to creating and configuring a Slack app for use with NanoClaw.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Slack workspace where you have admin permissions (or permission to install apps)
|
||||
- Your NanoClaw instance with the `/add-slack` skill applied
|
||||
|
||||
## Step 1: Create the Slack App
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps)
|
||||
2. Click **Create New App**
|
||||
3. Choose **From scratch**
|
||||
4. Enter an app name (e.g., your `ASSISTANT_NAME` value, or any name you like)
|
||||
5. Select the workspace you want to install it in
|
||||
6. Click **Create App**
|
||||
|
||||
## Step 2: Enable Socket Mode
|
||||
|
||||
Socket Mode lets the bot connect to Slack without needing a public URL. This is what makes it work from your local machine.
|
||||
|
||||
1. In the sidebar, click **Socket Mode**
|
||||
2. Toggle **Enable Socket Mode** to **On**
|
||||
3. When prompted for a token name, enter something like `nanoclaw`
|
||||
4. Click **Generate**
|
||||
5. **Copy the App-Level Token** — it starts with `xapp-`. Save this somewhere safe; you'll need it later.
|
||||
|
||||
## Step 3: Subscribe to Events
|
||||
|
||||
This tells Slack which messages to forward to your bot.
|
||||
|
||||
1. In the sidebar, click **Event Subscriptions**
|
||||
2. Toggle **Enable Events** to **On**
|
||||
3. Under **Subscribe to bot events**, click **Add Bot User Event** and add these three events:
|
||||
|
||||
| Event | What it does |
|
||||
|-------|-------------|
|
||||
| `message.channels` | Receive messages in public channels the bot is in |
|
||||
| `message.groups` | Receive messages in private channels the bot is in |
|
||||
| `message.im` | Receive direct messages to the bot |
|
||||
|
||||
4. Click **Save Changes** at the bottom of the page
|
||||
|
||||
## Step 4: Set Bot Permissions (OAuth Scopes)
|
||||
|
||||
These scopes control what the bot is allowed to do.
|
||||
|
||||
1. In the sidebar, click **OAuth & Permissions**
|
||||
2. Scroll down to **Scopes** > **Bot Token Scopes**
|
||||
3. Click **Add an OAuth Scope** and add each of these:
|
||||
|
||||
| Scope | Why it's needed |
|
||||
|-------|----------------|
|
||||
| `chat:write` | Send messages to channels and DMs |
|
||||
| `channels:history` | Read messages in public channels |
|
||||
| `groups:history` | Read messages in private channels |
|
||||
| `im:history` | Read direct messages |
|
||||
| `channels:read` | List channels (for metadata sync) |
|
||||
| `groups:read` | List private channels (for metadata sync) |
|
||||
| `users:read` | Look up user display names |
|
||||
|
||||
## Step 5: Install to Workspace
|
||||
|
||||
1. In the sidebar, click **Install App**
|
||||
2. Click **Install to Workspace**
|
||||
3. Review the permissions and click **Allow**
|
||||
4. **Copy the Bot User OAuth Token** — it starts with `xoxb-`. Save this somewhere safe.
|
||||
|
||||
## Step 6: Configure NanoClaw
|
||||
|
||||
Add both tokens to your `.env` file:
|
||||
|
||||
```
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
|
||||
SLACK_APP_TOKEN=xapp-your-app-token-here
|
||||
```
|
||||
|
||||
If you want Slack to replace WhatsApp entirely (no WhatsApp channel), also add:
|
||||
|
||||
```
|
||||
SLACK_ONLY=true
|
||||
```
|
||||
|
||||
Then sync the environment to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## Step 7: Add the Bot to Channels
|
||||
|
||||
The bot only receives messages from channels it has been explicitly added to.
|
||||
|
||||
1. Open the Slack channel you want the bot to monitor
|
||||
2. Click the channel name at the top to open channel details
|
||||
3. Go to **Integrations** > **Add apps**
|
||||
4. Search for your bot name and add it
|
||||
|
||||
Repeat for each channel you want the bot in.
|
||||
|
||||
## Step 8: Get Channel IDs for Registration
|
||||
|
||||
You need the Slack channel ID to register it with NanoClaw.
|
||||
|
||||
**Option A — From the URL:**
|
||||
Open the channel in Slack on the web. The URL looks like:
|
||||
```
|
||||
https://app.slack.com/client/TXXXXXXX/C0123456789
|
||||
```
|
||||
The `C0123456789` part is the channel ID.
|
||||
|
||||
**Option B — Right-click:**
|
||||
Right-click the channel name in Slack > **Copy link** > the channel ID is the last path segment.
|
||||
|
||||
**Option C — Via API:**
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
"https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'
|
||||
```
|
||||
|
||||
The NanoClaw JID format is `slack:` followed by the channel ID, e.g., `slack:C0123456789`.
|
||||
|
||||
## Token Reference
|
||||
|
||||
| Token | Prefix | Where to find it |
|
||||
|-------|--------|-----------------|
|
||||
| Bot User OAuth Token | `xoxb-` | **OAuth & Permissions** > **Bot User OAuth Token** |
|
||||
| App-Level Token | `xapp-` | **Basic Information** > **App-Level Tokens** (or during Socket Mode setup) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot not receiving messages:**
|
||||
- Verify Socket Mode is enabled (Step 2)
|
||||
- Verify all three events are subscribed (Step 3)
|
||||
- Verify the bot has been added to the channel (Step 7)
|
||||
|
||||
**"missing_scope" errors:**
|
||||
- Go back to **OAuth & Permissions** and add the missing scope
|
||||
- After adding scopes, you must **reinstall the app** to your workspace (Slack will show a banner prompting you to do this)
|
||||
|
||||
**Bot can't send messages:**
|
||||
- Verify the `chat:write` scope is added
|
||||
- Verify the bot has been added to the target channel
|
||||
|
||||
**Token not working:**
|
||||
- Bot tokens start with `xoxb-` — if yours doesn't, you may have copied the wrong token
|
||||
- App tokens start with `xapp-` — these are generated in the Socket Mode or Basic Information pages
|
||||
- If you regenerated a token, update `.env` and re-sync: `cp .env data/env/env`
|
||||
@@ -1,848 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Jonesy',
|
||||
TRIGGER_PATTERN: /^@Jonesy\b/i,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock db
|
||||
vi.mock('../db.js', () => ({
|
||||
updateChatName: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- @slack/bolt mock ---
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const appRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('@slack/bolt', () => ({
|
||||
App: class MockApp {
|
||||
eventHandlers = new Map<string, Handler>();
|
||||
token: string;
|
||||
appToken: string;
|
||||
|
||||
client = {
|
||||
auth: {
|
||||
test: vi.fn().mockResolvedValue({ user_id: 'U_BOT_123' }),
|
||||
},
|
||||
chat: {
|
||||
postMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
conversations: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
channels: [],
|
||||
response_metadata: {},
|
||||
}),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { real_name: 'Alice Smith', name: 'alice' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
constructor(opts: any) {
|
||||
this.token = opts.token;
|
||||
this.appToken = opts.appToken;
|
||||
appRef.current = this;
|
||||
}
|
||||
|
||||
event(name: string, handler: Handler) {
|
||||
this.eventHandlers.set(name, handler);
|
||||
}
|
||||
|
||||
async start() {}
|
||||
async stop() {}
|
||||
},
|
||||
LogLevel: { ERROR: 'error' },
|
||||
}));
|
||||
|
||||
// Mock env
|
||||
vi.mock('../env.js', () => ({
|
||||
readEnvFile: vi.fn().mockReturnValue({
|
||||
SLACK_BOT_TOKEN: 'xoxb-test-token',
|
||||
SLACK_APP_TOKEN: 'xapp-test-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SlackChannel, SlackChannelOpts } from './slack.js';
|
||||
import { updateChatName } from '../db.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(
|
||||
overrides?: Partial<SlackChannelOpts>,
|
||||
): SlackChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'slack:C0123456789': {
|
||||
name: 'Test Channel',
|
||||
folder: 'test-channel',
|
||||
trigger: '@Jonesy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageEvent(overrides: {
|
||||
channel?: string;
|
||||
channelType?: string;
|
||||
user?: string;
|
||||
text?: string;
|
||||
ts?: string;
|
||||
threadTs?: string;
|
||||
subtype?: string;
|
||||
botId?: string;
|
||||
}) {
|
||||
return {
|
||||
channel: overrides.channel ?? 'C0123456789',
|
||||
channel_type: overrides.channelType ?? 'channel',
|
||||
user: overrides.user ?? 'U_USER_456',
|
||||
text: 'text' in overrides ? overrides.text : 'Hello everyone',
|
||||
ts: overrides.ts ?? '1704067200.000000',
|
||||
thread_ts: overrides.threadTs,
|
||||
subtype: overrides.subtype,
|
||||
bot_id: overrides.botId,
|
||||
};
|
||||
}
|
||||
|
||||
function currentApp() {
|
||||
return appRef.current;
|
||||
}
|
||||
|
||||
async function triggerMessageEvent(event: ReturnType<typeof createMessageEvent>) {
|
||||
const handler = currentApp().eventHandlers.get('message');
|
||||
if (handler) await handler({ event });
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('SlackChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('resolves connect() when app starts', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('registers message event handler on construction', () => {
|
||||
const opts = createTestOpts();
|
||||
new SlackChannel(opts);
|
||||
|
||||
expect(currentApp().eventHandlers.has('message')).toBe(true);
|
||||
});
|
||||
|
||||
it('gets bot user ID on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(currentApp().client.auth.test).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected() returns false before connect', () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Message handling ---
|
||||
|
||||
describe('message handling', () => {
|
||||
it('delivers message for registered channel', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ text: 'Hello everyone' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'slack',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
id: '1704067200.000000',
|
||||
chat_jid: 'slack:C0123456789',
|
||||
sender: 'U_USER_456',
|
||||
content: 'Hello everyone',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only emits metadata for unregistered channels', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ channel: 'C9999999999' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'slack:C9999999999',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'slack',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-text subtypes (channel_join, etc.)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ subtype: 'channel_join' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows bot_message subtype through', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
subtype: 'bot_message',
|
||||
botId: 'B_OTHER_BOT',
|
||||
text: 'Bot message',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips messages with no text', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ text: undefined as any });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects bot messages by bot_id', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
subtype: 'bot_message',
|
||||
botId: 'B_MY_BOT',
|
||||
text: 'Bot response',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
// Has bot_id so should be marked as bot message
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
is_from_me: true,
|
||||
is_bot_message: true,
|
||||
sender_name: 'Jonesy',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('detects bot messages by matching bot user ID', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ user: 'U_BOT_123', text: 'Self message' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
is_from_me: true,
|
||||
is_bot_message: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('identifies IM channel type as non-group', async () => {
|
||||
const opts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'slack:D0123456789': {
|
||||
name: 'DM',
|
||||
folder: 'dm',
|
||||
trigger: '@Jonesy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
channel: 'D0123456789',
|
||||
channelType: 'im',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'slack:D0123456789',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'slack',
|
||||
false, // IM is not a group
|
||||
);
|
||||
});
|
||||
|
||||
it('converts ts to ISO timestamp', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ ts: '1704067200.000000' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves user name from Slack API', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ user: 'U_USER_456', text: 'Hello' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(currentApp().client.users.info).toHaveBeenCalledWith({
|
||||
user: 'U_USER_456',
|
||||
});
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
sender_name: 'Alice Smith',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('caches user names to avoid repeated API calls', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
// First message — API call
|
||||
await triggerMessageEvent(createMessageEvent({ user: 'U_USER_456', text: 'First' }));
|
||||
// Second message — should use cache
|
||||
await triggerMessageEvent(createMessageEvent({
|
||||
user: 'U_USER_456',
|
||||
text: 'Second',
|
||||
ts: '1704067201.000000',
|
||||
}));
|
||||
|
||||
expect(currentApp().client.users.info).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to user ID when API fails', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
currentApp().client.users.info.mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
const event = createMessageEvent({ user: 'U_UNKNOWN', text: 'Hi' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
sender_name: 'U_UNKNOWN',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('flattens threaded replies into channel messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
ts: '1704067201.000000',
|
||||
threadTs: '1704067200.000000', // parent message ts — this is a reply
|
||||
text: 'Thread reply',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
// Threaded replies are delivered as regular channel messages
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: 'Thread reply',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('delivers thread parent messages normally', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
ts: '1704067200.000000',
|
||||
threadTs: '1704067200.000000', // same as ts — this IS the parent
|
||||
text: 'Thread parent',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: 'Thread parent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('delivers messages without thread_ts normally', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({ text: 'Normal message' });
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- @mention translation ---
|
||||
|
||||
describe('@mention translation', () => {
|
||||
it('prepends trigger when bot is @mentioned via Slack format', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect(); // sets botUserId to 'U_BOT_123'
|
||||
|
||||
const event = createMessageEvent({
|
||||
text: 'Hey <@U_BOT_123> what do you think?',
|
||||
user: 'U_USER_456',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: '@Jonesy Hey <@U_BOT_123> what do you think?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prepend trigger when trigger pattern already matches', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
text: '@Jonesy <@U_BOT_123> hello',
|
||||
user: 'U_USER_456',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
// Content should be unchanged since it already matches TRIGGER_PATTERN
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: '@Jonesy <@U_BOT_123> hello',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate mentions in bot messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
text: 'Echo: <@U_BOT_123>',
|
||||
subtype: 'bot_message',
|
||||
botId: 'B_MY_BOT',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
// Bot messages skip mention translation
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: 'Echo: <@U_BOT_123>',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate mentions for other users', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const event = createMessageEvent({
|
||||
text: 'Hey <@U_OTHER_USER> look at this',
|
||||
user: 'U_USER_456',
|
||||
});
|
||||
await triggerMessageEvent(event);
|
||||
|
||||
// Mention is for a different user, not the bot
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'slack:C0123456789',
|
||||
expect.objectContaining({
|
||||
content: 'Hey <@U_OTHER_USER> look at this',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- sendMessage ---
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('sends message via Slack client', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('slack:C0123456789', 'Hello');
|
||||
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: 'C0123456789',
|
||||
text: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips slack: prefix from JID', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('slack:D9876543210', 'DM message');
|
||||
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: 'D9876543210',
|
||||
text: 'DM message',
|
||||
});
|
||||
});
|
||||
|
||||
it('queues message when disconnected', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
// Don't connect — should queue
|
||||
await channel.sendMessage('slack:C0123456789', 'Queued message');
|
||||
|
||||
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues message on send failure', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
currentApp().client.chat.postMessage.mockRejectedValueOnce(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
channel.sendMessage('slack:C0123456789', 'Will fail'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('splits long messages at 4000 character boundary', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
// Create a message longer than 4000 chars
|
||||
const longText = 'A'.repeat(4500);
|
||||
await channel.sendMessage('slack:C0123456789', longText);
|
||||
|
||||
// Should be split into 2 messages: 4000 + 500
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(1, {
|
||||
channel: 'C0123456789',
|
||||
text: 'A'.repeat(4000),
|
||||
});
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(2, {
|
||||
channel: 'C0123456789',
|
||||
text: 'A'.repeat(500),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends exactly-4000-char messages as a single message', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const text = 'B'.repeat(4000);
|
||||
await channel.sendMessage('slack:C0123456789', text);
|
||||
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: 'C0123456789',
|
||||
text,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits messages into 3 parts when over 8000 chars', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
await channel.connect();
|
||||
|
||||
const longText = 'C'.repeat(8500);
|
||||
await channel.sendMessage('slack:C0123456789', longText);
|
||||
|
||||
// 4000 + 4000 + 500 = 3 messages
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('flushes queued messages on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
// Queue messages while disconnected
|
||||
await channel.sendMessage('slack:C0123456789', 'First queued');
|
||||
await channel.sendMessage('slack:C0123456789', 'Second queued');
|
||||
|
||||
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
// Connect triggers flush
|
||||
await channel.connect();
|
||||
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: 'C0123456789',
|
||||
text: 'First queued',
|
||||
});
|
||||
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
||||
channel: 'C0123456789',
|
||||
text: 'Second queued',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('owns slack: JIDs', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('slack:C0123456789')).toBe(true);
|
||||
});
|
||||
|
||||
it('owns slack: DM JIDs', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('slack:D0123456789')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp group JIDs', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp DM JIDs', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own Telegram JIDs', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('tg:123456')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own unknown JID formats', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- syncChannelMetadata ---
|
||||
|
||||
describe('syncChannelMetadata', () => {
|
||||
it('calls conversations.list and updates chat names', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
currentApp().client.conversations.list.mockResolvedValue({
|
||||
channels: [
|
||||
{ id: 'C001', name: 'general', is_member: true },
|
||||
{ id: 'C002', name: 'random', is_member: true },
|
||||
{ id: 'C003', name: 'external', is_member: false },
|
||||
],
|
||||
response_metadata: {},
|
||||
});
|
||||
|
||||
await channel.connect();
|
||||
|
||||
// connect() calls syncChannelMetadata internally
|
||||
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
|
||||
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
|
||||
// Non-member channels are skipped
|
||||
expect(updateChatName).not.toHaveBeenCalledWith('slack:C003', 'external');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
currentApp().client.conversations.list.mockRejectedValue(
|
||||
new Error('API error'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(channel.connect()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('resolves without error (no-op)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
// Should not throw — Slack has no bot typing indicator API
|
||||
await expect(
|
||||
channel.setTyping('slack:C0123456789', true),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts false without error', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
await expect(
|
||||
channel.setTyping('slack:C0123456789', false),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Constructor error handling ---
|
||||
|
||||
describe('constructor', () => {
|
||||
it('throws when SLACK_BOT_TOKEN is missing', () => {
|
||||
vi.mocked(readEnvFile).mockReturnValueOnce({
|
||||
SLACK_BOT_TOKEN: '',
|
||||
SLACK_APP_TOKEN: 'xapp-test-token',
|
||||
});
|
||||
|
||||
expect(() => new SlackChannel(createTestOpts())).toThrow(
|
||||
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when SLACK_APP_TOKEN is missing', () => {
|
||||
vi.mocked(readEnvFile).mockReturnValueOnce({
|
||||
SLACK_BOT_TOKEN: 'xoxb-test-token',
|
||||
SLACK_APP_TOKEN: '',
|
||||
});
|
||||
|
||||
expect(() => new SlackChannel(createTestOpts())).toThrow(
|
||||
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- syncChannelMetadata pagination ---
|
||||
|
||||
describe('syncChannelMetadata pagination', () => {
|
||||
it('paginates through multiple pages of channels', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new SlackChannel(opts);
|
||||
|
||||
// First page returns a cursor; second page returns no cursor
|
||||
currentApp().client.conversations.list
|
||||
.mockResolvedValueOnce({
|
||||
channels: [
|
||||
{ id: 'C001', name: 'general', is_member: true },
|
||||
],
|
||||
response_metadata: { next_cursor: 'cursor_page2' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
channels: [
|
||||
{ id: 'C002', name: 'random', is_member: true },
|
||||
],
|
||||
response_metadata: {},
|
||||
});
|
||||
|
||||
await channel.connect();
|
||||
|
||||
// Should have called conversations.list twice (once per page)
|
||||
expect(currentApp().client.conversations.list).toHaveBeenCalledTimes(2);
|
||||
expect(currentApp().client.conversations.list).toHaveBeenNthCalledWith(2,
|
||||
expect.objectContaining({ cursor: 'cursor_page2' }),
|
||||
);
|
||||
|
||||
// Both channels from both pages stored
|
||||
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
|
||||
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
it('has name "slack"', () => {
|
||||
const channel = new SlackChannel(createTestOpts());
|
||||
expect(channel.name).toBe('slack');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
import { App, LogLevel } from '@slack/bolt';
|
||||
import type { GenericMessageEvent, BotMessageEvent } from '@slack/types';
|
||||
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||
import { updateChatName } from '../db.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
Channel,
|
||||
OnInboundMessage,
|
||||
OnChatMetadata,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
// Slack's chat.postMessage API limits text to ~4000 characters per call.
|
||||
// Messages exceeding this are split into sequential chunks.
|
||||
const MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
// The message subtypes we process. Bolt delivers all subtypes via app.event('message');
|
||||
// we filter to regular messages (GenericMessageEvent, subtype undefined) and bot messages
|
||||
// (BotMessageEvent, subtype 'bot_message') so we can track our own output.
|
||||
type HandledMessageEvent = GenericMessageEvent | BotMessageEvent;
|
||||
|
||||
export interface SlackChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class SlackChannel implements Channel {
|
||||
name = 'slack';
|
||||
|
||||
private app: App;
|
||||
private botUserId: string | undefined;
|
||||
private connected = false;
|
||||
private outgoingQueue: Array<{ jid: string; text: string }> = [];
|
||||
private flushing = false;
|
||||
private userNameCache = new Map<string, string>();
|
||||
|
||||
private opts: SlackChannelOpts;
|
||||
|
||||
constructor(opts: SlackChannelOpts) {
|
||||
this.opts = opts;
|
||||
|
||||
// Read tokens from .env (not process.env — keeps secrets off the environment
|
||||
// so they don't leak to child processes, matching NanoClaw's security pattern)
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
|
||||
const botToken = env.SLACK_BOT_TOKEN;
|
||||
const appToken = env.SLACK_APP_TOKEN;
|
||||
|
||||
if (!botToken || !appToken) {
|
||||
throw new Error(
|
||||
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
|
||||
);
|
||||
}
|
||||
|
||||
this.app = new App({
|
||||
token: botToken,
|
||||
appToken,
|
||||
socketMode: true,
|
||||
logLevel: LogLevel.ERROR,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Use app.event('message') instead of app.message() to capture all
|
||||
// message subtypes including bot_message (needed to track our own output)
|
||||
this.app.event('message', async ({ event }) => {
|
||||
// Bolt's event type is the full MessageEvent union (17+ subtypes).
|
||||
// We filter on subtype first, then narrow to the two types we handle.
|
||||
const subtype = (event as { subtype?: string }).subtype;
|
||||
if (subtype && subtype !== 'bot_message') return;
|
||||
|
||||
// After filtering, event is either GenericMessageEvent or BotMessageEvent
|
||||
const msg = event as HandledMessageEvent;
|
||||
|
||||
if (!msg.text) return;
|
||||
|
||||
// Threaded replies are flattened into the channel conversation.
|
||||
// The agent sees them alongside channel-level messages; responses
|
||||
// always go to the channel, not back into the thread.
|
||||
|
||||
const jid = `slack:${msg.channel}`;
|
||||
const timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString();
|
||||
const isGroup = msg.channel_type !== 'im';
|
||||
|
||||
// Always report metadata for group discovery
|
||||
this.opts.onChatMetadata(jid, timestamp, undefined, 'slack', isGroup);
|
||||
|
||||
// Only deliver full messages for registered groups
|
||||
const groups = this.opts.registeredGroups();
|
||||
if (!groups[jid]) return;
|
||||
|
||||
const isBotMessage =
|
||||
!!msg.bot_id || msg.user === this.botUserId;
|
||||
|
||||
let senderName: string;
|
||||
if (isBotMessage) {
|
||||
senderName = ASSISTANT_NAME;
|
||||
} else {
|
||||
senderName =
|
||||
(await this.resolveUserName(msg.user)) ||
|
||||
msg.user ||
|
||||
'unknown';
|
||||
}
|
||||
|
||||
// Translate Slack <@UBOTID> mentions into TRIGGER_PATTERN format.
|
||||
// Slack encodes @mentions as <@U12345>, which won't match TRIGGER_PATTERN
|
||||
// (e.g., ^@<ASSISTANT_NAME>\b), so we prepend the trigger when the bot is @mentioned.
|
||||
let content = msg.text;
|
||||
if (this.botUserId && !isBotMessage) {
|
||||
const mentionPattern = `<@${this.botUserId}>`;
|
||||
if (content.includes(mentionPattern) && !TRIGGER_PATTERN.test(content)) {
|
||||
content = `@${ASSISTANT_NAME} ${content}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.opts.onMessage(jid, {
|
||||
id: msg.ts,
|
||||
chat_jid: jid,
|
||||
sender: msg.user || msg.bot_id || '',
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: isBotMessage,
|
||||
is_bot_message: isBotMessage,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.app.start();
|
||||
|
||||
// Get bot's own user ID for self-message detection.
|
||||
// Resolve this BEFORE setting connected=true so that messages arriving
|
||||
// during startup can correctly detect bot-sent messages.
|
||||
try {
|
||||
const auth = await this.app.client.auth.test();
|
||||
this.botUserId = auth.user_id as string;
|
||||
logger.info({ botUserId: this.botUserId }, 'Connected to Slack');
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Connected to Slack but failed to get bot user ID',
|
||||
);
|
||||
}
|
||||
|
||||
this.connected = true;
|
||||
|
||||
// Flush any messages queued before connection
|
||||
await this.flushOutgoingQueue();
|
||||
|
||||
// Sync channel names on startup
|
||||
await this.syncChannelMetadata();
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
const channelId = jid.replace(/^slack:/, '');
|
||||
|
||||
if (!this.connected) {
|
||||
this.outgoingQueue.push({ jid, text });
|
||||
logger.info(
|
||||
{ jid, queueSize: this.outgoingQueue.length },
|
||||
'Slack disconnected, message queued',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Slack limits messages to ~4000 characters; split if needed
|
||||
if (text.length <= MAX_MESSAGE_LENGTH) {
|
||||
await this.app.client.chat.postMessage({ channel: channelId, text });
|
||||
} else {
|
||||
for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
|
||||
await this.app.client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: text.slice(i, i + MAX_MESSAGE_LENGTH),
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info({ jid, length: text.length }, 'Slack message sent');
|
||||
} catch (err) {
|
||||
this.outgoingQueue.push({ jid, text });
|
||||
logger.warn(
|
||||
{ jid, err, queueSize: this.outgoingQueue.length },
|
||||
'Failed to send Slack message, queued',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('slack:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
await this.app.stop();
|
||||
}
|
||||
|
||||
// Slack does not expose a typing indicator API for bots.
|
||||
// This no-op satisfies the Channel interface so the orchestrator
|
||||
// doesn't need channel-specific branching.
|
||||
async setTyping(_jid: string, _isTyping: boolean): Promise<void> {
|
||||
// no-op: Slack Bot API has no typing indicator endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync channel metadata from Slack.
|
||||
* Fetches channels the bot is a member of and stores their names in the DB.
|
||||
*/
|
||||
async syncChannelMetadata(): Promise<void> {
|
||||
try {
|
||||
logger.info('Syncing channel metadata from Slack...');
|
||||
let cursor: string | undefined;
|
||||
let count = 0;
|
||||
|
||||
do {
|
||||
const result = await this.app.client.conversations.list({
|
||||
types: 'public_channel,private_channel',
|
||||
exclude_archived: true,
|
||||
limit: 200,
|
||||
cursor,
|
||||
});
|
||||
|
||||
for (const ch of result.channels || []) {
|
||||
if (ch.id && ch.name && ch.is_member) {
|
||||
updateChatName(`slack:${ch.id}`, ch.name);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = result.response_metadata?.next_cursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info({ count }, 'Slack channel metadata synced');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to sync Slack channel metadata');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveUserName(
|
||||
userId: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!userId) return undefined;
|
||||
|
||||
const cached = this.userNameCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const result = await this.app.client.users.info({ user: userId });
|
||||
const name = result.user?.real_name || result.user?.name;
|
||||
if (name) this.userNameCache.set(userId, name);
|
||||
return name;
|
||||
} catch (err) {
|
||||
logger.debug({ userId, err }, 'Failed to resolve Slack user name');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async flushOutgoingQueue(): Promise<void> {
|
||||
if (this.flushing || this.outgoingQueue.length === 0) return;
|
||||
this.flushing = true;
|
||||
try {
|
||||
logger.info(
|
||||
{ count: this.outgoingQueue.length },
|
||||
'Flushing Slack outgoing queue',
|
||||
);
|
||||
while (this.outgoingQueue.length > 0) {
|
||||
const item = this.outgoingQueue.shift()!;
|
||||
const channelId = item.jid.replace(/^slack:/, '');
|
||||
await this.app.client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: item.text,
|
||||
});
|
||||
logger.info(
|
||||
{ jid: item.jid, length: item.text.length },
|
||||
'Queued Slack message sent',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
skill: slack
|
||||
version: 1.0.0
|
||||
description: "Slack Bot integration via @slack/bolt with Socket Mode"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/channels/slack.ts
|
||||
- src/channels/slack.test.ts
|
||||
modifies:
|
||||
- src/index.ts
|
||||
- src/config.ts
|
||||
- src/routing.test.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"
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
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<string, RegisteredGroup>): 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<boolean> {
|
||||
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<typeof setTimeout> | 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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<void>,
|
||||
): 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<void> {
|
||||
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<string, NewMessage[]>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
@@ -1,171 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('slack 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: slack');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('@slack/bolt');
|
||||
});
|
||||
|
||||
it('has all files declared in adds', () => {
|
||||
const addFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.ts');
|
||||
expect(fs.existsSync(addFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(addFile, 'utf-8');
|
||||
expect(content).toContain('class SlackChannel');
|
||||
expect(content).toContain('implements Channel');
|
||||
|
||||
// Test file for the channel
|
||||
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'slack.test.ts');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain("describe('SlackChannel'");
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('has setup documentation', () => {
|
||||
expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true);
|
||||
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'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Channel interface methods
|
||||
expect(content).toContain('async connect()');
|
||||
expect(content).toContain('async sendMessage(');
|
||||
expect(content).toContain('isConnected()');
|
||||
expect(content).toContain('ownsJid(');
|
||||
expect(content).toContain('async disconnect()');
|
||||
expect(content).toContain('async setTyping(');
|
||||
|
||||
// Security pattern: reads tokens from .env, not process.env
|
||||
expect(content).toContain('readEnvFile');
|
||||
expect(content).not.toContain('process.env.SLACK_BOT_TOKEN');
|
||||
expect(content).not.toContain('process.env.SLACK_APP_TOKEN');
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -5,68 +5,65 @@ description: Add Telegram as a channel. Can replace WhatsApp entirely or run alo
|
||||
|
||||
# Add Telegram Channel
|
||||
|
||||
This skill adds Telegram support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
|
||||
This skill adds Telegram support to NanoClaw, then walks through interactive setup.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
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.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
|
||||
|
||||
### Apply the skill
|
||||
If `telegram` is missing, add it:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||
git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `src/channels/telegram.ts` (TelegramChannel class implementing Channel interface)
|
||||
- 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`
|
||||
- Installs the `grammy` npm dependency
|
||||
- Updates `.env.example` with `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY`
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
### Merge the skill branch
|
||||
|
||||
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
|
||||
```bash
|
||||
git fetch telegram main
|
||||
git merge telegram/main || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/telegram.test.ts` (unit tests with grammy mock)
|
||||
- `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- `grammy` npm dependency in `package.json`
|
||||
- `TELEGRAM_BOT_TOKEN` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/channels/telegram.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the new telegram tests) and build must be clean before proceeding.
|
||||
All tests must pass (including the new Telegram tests) and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
@@ -92,11 +89,7 @@ Add to `.env`:
|
||||
TELEGRAM_BOT_TOKEN=<their-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:
|
||||
|
||||
@@ -140,30 +133,18 @@ Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234
|
||||
|
||||
### Register the chat
|
||||
|
||||
Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
|
||||
The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
|
||||
|
||||
For a main chat (responds to all messages, uses the `main` folder):
|
||||
For a main chat (responds to all messages):
|
||||
|
||||
```typescript
|
||||
registerGroup("tg:<chat-id>", {
|
||||
name: "<chat-name>",
|
||||
folder: "main",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main
|
||||
```
|
||||
|
||||
For additional chats (trigger-only):
|
||||
|
||||
```typescript
|
||||
registerGroup("tg:<chat-id>", {
|
||||
name: "<chat-name>",
|
||||
folder: "<folder-name>",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: true,
|
||||
});
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_<group-name>" --trigger "@${ASSISTANT_NAME}" --channel telegram
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
@@ -233,11 +214,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)
|
||||
|
||||
@@ -1,926 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Grammy mock ---
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const botRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('grammy', () => ({
|
||||
Bot: class MockBot {
|
||||
token: string;
|
||||
commandHandlers = new Map<string, Handler>();
|
||||
filterHandlers = new Map<string, Handler[]>();
|
||||
errorHandler: Handler | null = null;
|
||||
|
||||
api = {
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendChatAction: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
botRef.current = this;
|
||||
}
|
||||
|
||||
command(name: string, handler: Handler) {
|
||||
this.commandHandlers.set(name, handler);
|
||||
}
|
||||
|
||||
on(filter: string, handler: Handler) {
|
||||
const existing = this.filterHandlers.get(filter) || [];
|
||||
existing.push(handler);
|
||||
this.filterHandlers.set(filter, existing);
|
||||
}
|
||||
|
||||
catch(handler: Handler) {
|
||||
this.errorHandler = handler;
|
||||
}
|
||||
|
||||
start(opts: { onStart: (botInfo: any) => void }) {
|
||||
opts.onStart({ username: 'andy_ai_bot', id: 12345 });
|
||||
}
|
||||
|
||||
stop() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import { TelegramChannel, TelegramChannelOpts } from './telegram.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(
|
||||
overrides?: Partial<TelegramChannelOpts>,
|
||||
): TelegramChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'tg:100200300': {
|
||||
name: 'Test Group',
|
||||
folder: 'test-group',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTextCtx(overrides: {
|
||||
chatId?: number;
|
||||
chatType?: string;
|
||||
chatTitle?: string;
|
||||
text: string;
|
||||
fromId?: number;
|
||||
firstName?: string;
|
||||
username?: string;
|
||||
messageId?: number;
|
||||
date?: number;
|
||||
entities?: any[];
|
||||
}) {
|
||||
const chatId = overrides.chatId ?? 100200300;
|
||||
const chatType = overrides.chatType ?? 'group';
|
||||
return {
|
||||
chat: {
|
||||
id: chatId,
|
||||
type: chatType,
|
||||
title: overrides.chatTitle ?? 'Test Group',
|
||||
},
|
||||
from: {
|
||||
id: overrides.fromId ?? 99001,
|
||||
first_name: overrides.firstName ?? 'Alice',
|
||||
username: overrides.username ?? 'alice_user',
|
||||
},
|
||||
message: {
|
||||
text: overrides.text,
|
||||
date: overrides.date ?? Math.floor(Date.now() / 1000),
|
||||
message_id: overrides.messageId ?? 1,
|
||||
entities: overrides.entities ?? [],
|
||||
},
|
||||
me: { username: 'andy_ai_bot' },
|
||||
reply: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaCtx(overrides: {
|
||||
chatId?: number;
|
||||
chatType?: string;
|
||||
fromId?: number;
|
||||
firstName?: string;
|
||||
date?: number;
|
||||
messageId?: number;
|
||||
caption?: string;
|
||||
extra?: Record<string, any>;
|
||||
}) {
|
||||
const chatId = overrides.chatId ?? 100200300;
|
||||
return {
|
||||
chat: {
|
||||
id: chatId,
|
||||
type: overrides.chatType ?? 'group',
|
||||
title: 'Test Group',
|
||||
},
|
||||
from: {
|
||||
id: overrides.fromId ?? 99001,
|
||||
first_name: overrides.firstName ?? 'Alice',
|
||||
username: 'alice_user',
|
||||
},
|
||||
message: {
|
||||
date: overrides.date ?? Math.floor(Date.now() / 1000),
|
||||
message_id: overrides.messageId ?? 1,
|
||||
caption: overrides.caption,
|
||||
...(overrides.extra || {}),
|
||||
},
|
||||
me: { username: 'andy_ai_bot' },
|
||||
};
|
||||
}
|
||||
|
||||
function currentBot() {
|
||||
return botRef.current;
|
||||
}
|
||||
|
||||
async function triggerTextMessage(ctx: ReturnType<typeof createTextCtx>) {
|
||||
const handlers = currentBot().filterHandlers.get('message:text') || [];
|
||||
for (const h of handlers) await h(ctx);
|
||||
}
|
||||
|
||||
async function triggerMediaMessage(
|
||||
filter: string,
|
||||
ctx: ReturnType<typeof createMediaCtx>,
|
||||
) {
|
||||
const handlers = currentBot().filterHandlers.get(filter) || [];
|
||||
for (const h of handlers) await h(ctx);
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('TelegramChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('resolves connect() when bot starts', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('registers command and message handlers on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(currentBot().commandHandlers.has('chatid')).toBe(true);
|
||||
expect(currentBot().commandHandlers.has('ping')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:text')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:video')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:document')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:location')).toBe(true);
|
||||
expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
|
||||
});
|
||||
|
||||
it('registers error handler on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(currentBot().errorHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected() returns false before connect', () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text message handling ---
|
||||
|
||||
describe('text message handling', () => {
|
||||
it('delivers message for registered group', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: 'Hello everyone' });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.any(String),
|
||||
'Test Group',
|
||||
'telegram',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
chat_jid: 'tg:100200300',
|
||||
sender: '99001',
|
||||
sender_name: 'Alice',
|
||||
content: 'Hello everyone',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only emits metadata for unregistered chats', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'tg:999999',
|
||||
expect.any(String),
|
||||
'Test Group',
|
||||
'telegram',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips command messages (starting with /)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: '/start' });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts sender name from first_name', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ sender_name: 'Bob' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to username when first_name missing', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: 'Hi' });
|
||||
ctx.from.first_name = undefined as any;
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ sender_name: 'alice_user' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to user ID when name and username missing', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
|
||||
ctx.from.first_name = undefined as any;
|
||||
ctx.from.username = undefined as any;
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ sender_name: '42' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses sender name as chat name for private chats', async () => {
|
||||
const opts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'tg:100200300': {
|
||||
name: 'Private',
|
||||
folder: 'private',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: 'Hello',
|
||||
chatType: 'private',
|
||||
firstName: 'Alice',
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.any(String),
|
||||
'Alice', // Private chats use sender name
|
||||
'telegram',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses chat title as name for group chats', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: 'Hello',
|
||||
chatType: 'supergroup',
|
||||
chatTitle: 'Project Team',
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.any(String),
|
||||
'Project Team',
|
||||
'telegram',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('converts message.date to ISO timestamp', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
|
||||
const ctx = createTextCtx({ text: 'Hello', date: unixTime });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- @mention translation ---
|
||||
|
||||
describe('@mention translation', () => {
|
||||
it('translates @bot_username mention to trigger format', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: '@andy_ai_bot what time is it?',
|
||||
entities: [{ type: 'mention', offset: 0, length: 12 }],
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: '@Andy @andy_ai_bot what time is it?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate if message already matches trigger', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: '@Andy @andy_ai_bot hello',
|
||||
entities: [{ type: 'mention', offset: 6, length: 12 }],
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
// Should NOT double-prepend — already starts with @Andy
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: '@Andy @andy_ai_bot hello',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate mentions of other bots', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: '@some_other_bot hi',
|
||||
entities: [{ type: 'mention', offset: 0, length: 15 }],
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: '@some_other_bot hi', // No translation
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles mention in middle of message', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: 'hey @andy_ai_bot check this',
|
||||
entities: [{ type: 'mention', offset: 4, length: 12 }],
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
// Bot is mentioned, message doesn't match trigger → prepend trigger
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: '@Andy hey @andy_ai_bot check this',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles message with no entities', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({ text: 'plain message' });
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: 'plain message',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores non-mention entities', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createTextCtx({
|
||||
text: 'check https://example.com',
|
||||
entities: [{ type: 'url', offset: 6, length: 19 }],
|
||||
});
|
||||
await triggerTextMessage(ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({
|
||||
content: 'check https://example.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Non-text messages ---
|
||||
|
||||
describe('non-text messages', () => {
|
||||
it('stores photo with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:photo', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Photo]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores photo with caption', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({ caption: 'Look at this' });
|
||||
await triggerMediaMessage('message:photo', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Photo] Look at this' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores video with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:video', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Video]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores voice message with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:voice', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Voice message]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores audio with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:audio', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Audio]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores document with filename', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({
|
||||
extra: { document: { file_name: 'report.pdf' } },
|
||||
});
|
||||
await triggerMediaMessage('message:document', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Document: report.pdf]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores document with fallback name when filename missing', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({ extra: { document: {} } });
|
||||
await triggerMediaMessage('message:document', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Document: file]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores sticker with emoji', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({
|
||||
extra: { sticker: { emoji: '😂' } },
|
||||
});
|
||||
await triggerMediaMessage('message:sticker', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Sticker 😂]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores location with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:location', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Location]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores contact with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({});
|
||||
await triggerMediaMessage('message:contact', ctx);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'tg:100200300',
|
||||
expect.objectContaining({ content: '[Contact]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores non-text messages from unregistered chats', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const ctx = createMediaCtx({ chatId: 999999 });
|
||||
await triggerMediaMessage('message:photo', ctx);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- sendMessage ---
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('sends message via bot API', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('tg:100200300', 'Hello');
|
||||
|
||||
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
|
||||
'100200300',
|
||||
'Hello',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips tg: prefix from JID', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('tg:-1001234567890', 'Group message');
|
||||
|
||||
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
|
||||
'-1001234567890',
|
||||
'Group message',
|
||||
);
|
||||
});
|
||||
|
||||
it('splits messages exceeding 4096 characters', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const longText = 'x'.repeat(5000);
|
||||
await channel.sendMessage('tg:100200300', longText);
|
||||
|
||||
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'100200300',
|
||||
'x'.repeat(4096),
|
||||
);
|
||||
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'100200300',
|
||||
'x'.repeat(904),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends exactly one message at 4096 characters', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const exactText = 'y'.repeat(4096);
|
||||
await channel.sendMessage('tg:100200300', exactText);
|
||||
|
||||
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles send failure gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
currentBot().api.sendMessage.mockRejectedValueOnce(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
channel.sendMessage('tg:100200300', 'Will fail'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when bot is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
// Don't connect — bot is null
|
||||
await channel.sendMessage('tg:100200300', 'No bot');
|
||||
|
||||
// No error, no API call
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('owns tg: JIDs', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('tg:123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('owns tg: JIDs with negative IDs (groups)', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp group JIDs', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp DM JIDs', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own unknown JID formats', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends typing action when isTyping is true', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.setTyping('tg:100200300', true);
|
||||
|
||||
expect(currentBot().api.sendChatAction).toHaveBeenCalledWith(
|
||||
'100200300',
|
||||
'typing',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when isTyping is false', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.setTyping('tg:100200300', false);
|
||||
|
||||
expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when bot is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
|
||||
// Don't connect
|
||||
await channel.setTyping('tg:100200300', true);
|
||||
|
||||
// No error, no API call
|
||||
});
|
||||
|
||||
it('handles typing indicator failure gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
currentBot().api.sendChatAction.mockRejectedValueOnce(
|
||||
new Error('Rate limited'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
channel.setTyping('tg:100200300', true),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bot commands ---
|
||||
|
||||
describe('bot commands', () => {
|
||||
it('/chatid replies with chat ID and metadata', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const handler = currentBot().commandHandlers.get('chatid')!;
|
||||
const ctx = {
|
||||
chat: { id: 100200300, type: 'group' as const },
|
||||
from: { first_name: 'Alice' },
|
||||
reply: vi.fn(),
|
||||
};
|
||||
|
||||
await handler(ctx);
|
||||
|
||||
expect(ctx.reply).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tg:100200300'),
|
||||
expect.objectContaining({ parse_mode: 'Markdown' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('/chatid shows chat type', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const handler = currentBot().commandHandlers.get('chatid')!;
|
||||
const ctx = {
|
||||
chat: { id: 555, type: 'private' as const },
|
||||
from: { first_name: 'Bob' },
|
||||
reply: vi.fn(),
|
||||
};
|
||||
|
||||
await handler(ctx);
|
||||
|
||||
expect(ctx.reply).toHaveBeenCalledWith(
|
||||
expect.stringContaining('private'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('/ping replies with bot status', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new TelegramChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const handler = currentBot().commandHandlers.get('ping')!;
|
||||
const ctx = { reply: vi.fn() };
|
||||
|
||||
await handler(ctx);
|
||||
|
||||
expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
it('has name "telegram"', () => {
|
||||
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||
expect(channel.name).toBe('telegram');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
import { Bot } from 'grammy';
|
||||
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
Channel,
|
||||
OnChatMetadata,
|
||||
OnInboundMessage,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
export interface TelegramChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class TelegramChannel implements Channel {
|
||||
name = 'telegram';
|
||||
|
||||
private bot: Bot | null = null;
|
||||
private opts: TelegramChannelOpts;
|
||||
private botToken: string;
|
||||
|
||||
constructor(botToken: string, opts: TelegramChannelOpts) {
|
||||
this.botToken = botToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.bot = new Bot(this.botToken);
|
||||
|
||||
// Command to get chat ID (useful for registration)
|
||||
this.bot.command('chatid', (ctx) => {
|
||||
const chatId = ctx.chat.id;
|
||||
const chatType = ctx.chat.type;
|
||||
const chatName =
|
||||
chatType === 'private'
|
||||
? ctx.from?.first_name || 'Private'
|
||||
: (ctx.chat as any).title || 'Unknown';
|
||||
|
||||
ctx.reply(
|
||||
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
|
||||
{ parse_mode: 'Markdown' },
|
||||
);
|
||||
});
|
||||
|
||||
// Command to check bot status
|
||||
this.bot.command('ping', (ctx) => {
|
||||
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
||||
});
|
||||
|
||||
this.bot.on('message:text', async (ctx) => {
|
||||
// Skip commands
|
||||
if (ctx.message.text.startsWith('/')) return;
|
||||
|
||||
const chatJid = `tg:${ctx.chat.id}`;
|
||||
let content = ctx.message.text;
|
||||
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||
const senderName =
|
||||
ctx.from?.first_name ||
|
||||
ctx.from?.username ||
|
||||
ctx.from?.id.toString() ||
|
||||
'Unknown';
|
||||
const sender = ctx.from?.id.toString() || '';
|
||||
const msgId = ctx.message.message_id.toString();
|
||||
|
||||
// Determine chat name
|
||||
const chatName =
|
||||
ctx.chat.type === 'private'
|
||||
? senderName
|
||||
: (ctx.chat as any).title || chatJid;
|
||||
|
||||
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
||||
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
||||
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
||||
const botUsername = ctx.me?.username?.toLowerCase();
|
||||
if (botUsername) {
|
||||
const entities = ctx.message.entities || [];
|
||||
const isBotMentioned = entities.some((entity) => {
|
||||
if (entity.type === 'mention') {
|
||||
const mentionText = content
|
||||
.substring(entity.offset, entity.offset + entity.length)
|
||||
.toLowerCase();
|
||||
return mentionText === `@${botUsername}`;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
||||
content = `@${ASSISTANT_NAME} ${content}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Store chat metadata for discovery
|
||||
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
||||
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup);
|
||||
|
||||
// Only deliver full message for registered groups
|
||||
const group = this.opts.registeredGroups()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug(
|
||||
{ chatJid, chatName },
|
||||
'Message from unregistered Telegram chat',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deliver message — startMessageLoop() will pick it up
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msgId,
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ chatJid, chatName, sender: senderName },
|
||||
'Telegram message stored',
|
||||
);
|
||||
});
|
||||
|
||||
// Handle non-text messages with placeholders so the agent knows something was sent
|
||||
const storeNonText = (ctx: any, placeholder: string) => {
|
||||
const chatJid = `tg:${ctx.chat.id}`;
|
||||
const group = this.opts.registeredGroups()[chatJid];
|
||||
if (!group) return;
|
||||
|
||||
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||
const senderName =
|
||||
ctx.from?.first_name ||
|
||||
ctx.from?.username ||
|
||||
ctx.from?.id?.toString() ||
|
||||
'Unknown';
|
||||
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
|
||||
|
||||
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
||||
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup);
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: ctx.message.message_id.toString(),
|
||||
chat_jid: chatJid,
|
||||
sender: ctx.from?.id?.toString() || '',
|
||||
sender_name: senderName,
|
||||
content: `${placeholder}${caption}`,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
};
|
||||
|
||||
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
|
||||
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
|
||||
this.bot.on('message:voice', (ctx) =>
|
||||
storeNonText(ctx, '[Voice message]'),
|
||||
);
|
||||
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
|
||||
this.bot.on('message:document', (ctx) => {
|
||||
const name = ctx.message.document?.file_name || 'file';
|
||||
storeNonText(ctx, `[Document: ${name}]`);
|
||||
});
|
||||
this.bot.on('message:sticker', (ctx) => {
|
||||
const emoji = ctx.message.sticker?.emoji || '';
|
||||
storeNonText(ctx, `[Sticker ${emoji}]`);
|
||||
});
|
||||
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
|
||||
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
|
||||
|
||||
// Handle errors gracefully
|
||||
this.bot.catch((err) => {
|
||||
logger.error({ err: err.message }, 'Telegram bot error');
|
||||
});
|
||||
|
||||
// Start polling — returns a Promise that resolves when started
|
||||
return new Promise<void>((resolve) => {
|
||||
this.bot!.start({
|
||||
onStart: (botInfo) => {
|
||||
logger.info(
|
||||
{ username: botInfo.username, id: botInfo.id },
|
||||
'Telegram bot connected',
|
||||
);
|
||||
console.log(`\n Telegram bot: @${botInfo.username}`);
|
||||
console.log(
|
||||
` Send /chatid to the bot to get a chat's registration ID\n`,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
if (!this.bot) {
|
||||
logger.warn('Telegram bot not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const numericId = jid.replace(/^tg:/, '');
|
||||
|
||||
// Telegram has a 4096 character limit per message — split if needed
|
||||
const MAX_LENGTH = 4096;
|
||||
if (text.length <= MAX_LENGTH) {
|
||||
await this.bot.api.sendMessage(numericId, text);
|
||||
} else {
|
||||
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||
await this.bot.api.sendMessage(
|
||||
numericId,
|
||||
text.slice(i, i + MAX_LENGTH),
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send Telegram message');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.bot !== null;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('tg:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.bot) {
|
||||
this.bot.stop();
|
||||
this.bot = null;
|
||||
logger.info('Telegram bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
if (!this.bot || !isTyping) return;
|
||||
try {
|
||||
const numericId = jid.replace(/^tg:/, '');
|
||||
await this.bot.api.sendChatAction(numericId, 'typing');
|
||||
} catch (err) {
|
||||
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
skill: telegram
|
||||
version: 1.0.0
|
||||
description: "Telegram Bot API integration via Grammy"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/channels/telegram.ts
|
||||
- src/channels/telegram.test.ts
|
||||
modifies:
|
||||
- src/index.ts
|
||||
- src/config.ts
|
||||
- src/routing.test.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"
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
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<string, RegisteredGroup>): 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<boolean> {
|
||||
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<typeof setTimeout> | 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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<void>,
|
||||
): 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<void> {
|
||||
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<string, NewMessage[]>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('telegram 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: telegram');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('grammy');
|
||||
});
|
||||
|
||||
it('has all files declared in adds', () => {
|
||||
const addFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.ts');
|
||||
expect(fs.existsSync(addFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(addFile, 'utf-8');
|
||||
expect(content).toContain('class TelegramChannel');
|
||||
expect(content).toContain('implements Channel');
|
||||
|
||||
// Test file for the channel
|
||||
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.test.ts');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain("describe('TelegramChannel'");
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ This skill adds automatic voice message transcription to NanoClaw's WhatsApp cha
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `voice-transcription` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
|
||||
Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
@@ -23,42 +23,49 @@ If yes, collect it now. If no, direct them to create one at https://platform.ope
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package.
|
||||
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### Apply the skill
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-voice-transcription
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `src/transcription.ts` (voice transcription module using OpenAI Whisper)
|
||||
- Three-way merges voice handling into `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
|
||||
- Three-way merges transcription tests into `src/channels/whatsapp.test.ts` (mock + 3 test cases)
|
||||
- Installs the `openai` npm dependency
|
||||
- Updates `.env.example` with `OPENAI_API_KEY`
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
### Merge the skill branch
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
- `modify/src/channels/whatsapp.ts.intent.md` — what changed and invariants for whatsapp.ts
|
||||
- `modify/src/channels/whatsapp.test.ts.intent.md` — what changed for whatsapp.test.ts
|
||||
```bash
|
||||
git fetch whatsapp skill/voice-transcription
|
||||
git merge whatsapp/skill/voice-transcription || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/transcription.ts` (voice transcription module using OpenAI Whisper)
|
||||
- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
|
||||
- Transcription tests in `src/channels/whatsapp.test.ts`
|
||||
- `openai` npm dependency in `package.json`
|
||||
- `OPENAI_API_KEY` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
npx vitest run src/channels/whatsapp.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the 3 new voice transcription tests) and build must be clean before proceeding.
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { downloadMediaMessage } from '@whiskeysockets/baileys';
|
||||
import { WAMessage, WASocket } from '@whiskeysockets/baileys';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
|
||||
interface TranscriptionConfig {
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
fallbackMessage: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: TranscriptionConfig = {
|
||||
model: 'whisper-1',
|
||||
enabled: true,
|
||||
fallbackMessage: '[Voice Message - transcription unavailable]',
|
||||
};
|
||||
|
||||
async function transcribeWithOpenAI(
|
||||
audioBuffer: Buffer,
|
||||
config: TranscriptionConfig,
|
||||
): Promise<string | null> {
|
||||
const env = readEnvFile(['OPENAI_API_KEY']);
|
||||
const apiKey = env.OPENAI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('OPENAI_API_KEY not set in .env');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const openaiModule = await import('openai');
|
||||
const OpenAI = openaiModule.default;
|
||||
const toFile = openaiModule.toFile;
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
const file = await toFile(audioBuffer, 'voice.ogg', {
|
||||
type: 'audio/ogg',
|
||||
});
|
||||
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: file,
|
||||
model: config.model,
|
||||
response_format: 'text',
|
||||
});
|
||||
|
||||
// When response_format is 'text', the API returns a plain string
|
||||
return transcription as unknown as string;
|
||||
} catch (err) {
|
||||
console.error('OpenAI transcription failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeAudioMessage(
|
||||
msg: WAMessage,
|
||||
sock: WASocket,
|
||||
): Promise<string | null> {
|
||||
const config = DEFAULT_CONFIG;
|
||||
|
||||
if (!config.enabled) {
|
||||
return config.fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = (await downloadMediaMessage(
|
||||
msg,
|
||||
'buffer',
|
||||
{},
|
||||
{
|
||||
logger: console as any,
|
||||
reuploadRequest: sock.updateMediaMessage,
|
||||
},
|
||||
)) as Buffer;
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
console.error('Failed to download audio message');
|
||||
return config.fallbackMessage;
|
||||
}
|
||||
|
||||
console.log(`Downloaded audio message: ${buffer.length} bytes`);
|
||||
|
||||
const transcript = await transcribeWithOpenAI(buffer, config);
|
||||
|
||||
if (!transcript) {
|
||||
return config.fallbackMessage;
|
||||
}
|
||||
|
||||
return transcript.trim();
|
||||
} catch (err) {
|
||||
console.error('Transcription error:', err);
|
||||
return config.fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export function isVoiceMessage(msg: WAMessage): boolean {
|
||||
return msg.message?.audioMessage?.ptt === true;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
skill: voice-transcription
|
||||
version: 1.0.0
|
||||
description: "Voice message transcription via OpenAI Whisper"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/transcription.ts
|
||||
modifies:
|
||||
- src/channels/whatsapp.ts
|
||||
- src/channels/whatsapp.test.ts
|
||||
structured:
|
||||
npm_dependencies:
|
||||
openai: "^4.77.0"
|
||||
env_additions:
|
||||
- OPENAI_API_KEY
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npx vitest run src/channels/whatsapp.test.ts"
|
||||
@@ -1,963 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config.js', () => ({
|
||||
STORE_DIR: '/tmp/nanoclaw-test-store',
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
ASSISTANT_HAS_OWN_NUMBER: false,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock db
|
||||
vi.mock('../db.js', () => ({
|
||||
getLastGroupSync: vi.fn(() => null),
|
||||
setLastGroupSync: vi.fn(),
|
||||
updateChatName: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock transcription
|
||||
vi.mock('../transcription.js', () => ({
|
||||
isVoiceMessage: vi.fn((msg: any) => msg.message?.audioMessage?.ptt === true),
|
||||
transcribeAudioMessage: vi.fn().mockResolvedValue('Hello this is a voice message'),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process (used for osascript notification)
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
// Build a fake WASocket that's an EventEmitter with the methods we need
|
||||
function createFakeSocket() {
|
||||
const ev = new EventEmitter();
|
||||
const sock = {
|
||||
ev: {
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
ev.on(event, handler);
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: '1234567890:1@s.whatsapp.net',
|
||||
lid: '9876543210:1@lid',
|
||||
},
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
|
||||
end: vi.fn(),
|
||||
// Expose the event emitter for triggering events in tests
|
||||
_ev: ev,
|
||||
};
|
||||
return sock;
|
||||
}
|
||||
|
||||
let fakeSocket: ReturnType<typeof createFakeSocket>;
|
||||
|
||||
// Mock Baileys
|
||||
vi.mock('@whiskeysockets/baileys', () => {
|
||||
return {
|
||||
default: vi.fn(() => fakeSocket),
|
||||
Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
|
||||
DisconnectReason: {
|
||||
loggedOut: 401,
|
||||
badSession: 500,
|
||||
connectionClosed: 428,
|
||||
connectionLost: 408,
|
||||
connectionReplaced: 440,
|
||||
timedOut: 408,
|
||||
restartRequired: 515,
|
||||
},
|
||||
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
|
||||
useMultiFileAuthState: vi.fn().mockResolvedValue({
|
||||
state: {
|
||||
creds: {},
|
||||
keys: {},
|
||||
},
|
||||
saveCreds: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
|
||||
import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
|
||||
import { transcribeAudioMessage } from '../transcription.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(overrides?: Partial<WhatsAppChannelOpts>): WhatsAppChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'registered@g.us': {
|
||||
name: 'Test Group',
|
||||
folder: 'test-group',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function triggerConnection(state: string, extra?: Record<string, unknown>) {
|
||||
fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
|
||||
}
|
||||
|
||||
function triggerDisconnect(statusCode: number) {
|
||||
fakeSocket._ev.emit('connection.update', {
|
||||
connection: 'close',
|
||||
lastDisconnect: {
|
||||
error: { output: { statusCode } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerMessages(messages: unknown[]) {
|
||||
fakeSocket._ev.emit('messages.upsert', { messages });
|
||||
// Flush microtasks so the async messages.upsert handler completes
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('WhatsAppChannel', () => {
|
||||
beforeEach(() => {
|
||||
fakeSocket = createFakeSocket();
|
||||
vi.mocked(getLastGroupSync).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: start connect, flush microtasks so event handlers are registered,
|
||||
* then trigger the connection open event. Returns the resolved promise.
|
||||
*/
|
||||
async function connectChannel(channel: WhatsAppChannel): Promise<void> {
|
||||
const p = channel.connect();
|
||||
// Flush microtasks so connectInternal completes its await and registers handlers
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
triggerConnection('open');
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('resolves connect() when connection opens', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('sets up LID to phone mapping on open', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// The channel should have mapped the LID from sock.user
|
||||
// We can verify by sending a message from a LID JID
|
||||
// and checking the translated JID in the callback
|
||||
});
|
||||
|
||||
it('flushes outgoing queue on reconnect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Disconnect
|
||||
(channel as any).connected = false;
|
||||
|
||||
// Queue a message while disconnected
|
||||
await channel.sendMessage('test@g.us', 'Queued message');
|
||||
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// Reconnect
|
||||
(channel as any).connected = true;
|
||||
await (channel as any).flushOutgoingQueue();
|
||||
|
||||
// Group messages get prefixed when flushed
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
|
||||
'test@g.us',
|
||||
{ text: 'Andy: Queued message' },
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
expect(fakeSocket.end).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- QR code and auth ---
|
||||
|
||||
describe('authentication', () => {
|
||||
it('exits process when QR code is emitted (no auth state)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
// Start connect but don't await (it won't resolve - process exits)
|
||||
channel.connect().catch(() => {});
|
||||
|
||||
// Flush microtasks so connectInternal registers handlers
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Emit QR code event
|
||||
fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
|
||||
|
||||
// Advance timer past the 1000ms setTimeout before exit
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
mockExit.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reconnection behavior ---
|
||||
|
||||
describe('reconnection', () => {
|
||||
it('reconnects on non-loggedOut disconnect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
|
||||
// Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
|
||||
triggerDisconnect(428);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
// The channel should attempt to reconnect (calls connectInternal again)
|
||||
});
|
||||
|
||||
it('exits on loggedOut disconnect', async () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Disconnect with loggedOut reason (401)
|
||||
triggerDisconnect(401);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
expect(mockExit).toHaveBeenCalledWith(0);
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
|
||||
it('retries reconnection after 5s on failure', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Disconnect with stream error 515
|
||||
triggerDisconnect(515);
|
||||
|
||||
// The channel sets a 5s retry — just verify it doesn't crash
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Message handling ---
|
||||
|
||||
describe('message handling', () => {
|
||||
it('delivers message for registered group', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-1',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'Hello Andy' },
|
||||
pushName: 'Alice',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({
|
||||
id: 'msg-1',
|
||||
content: 'Hello Andy',
|
||||
sender_name: 'Alice',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only emits metadata for unregistered groups', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-2',
|
||||
remoteJid: 'unregistered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'Hello' },
|
||||
pushName: 'Bob',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'unregistered@g.us',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores status@broadcast messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-3',
|
||||
remoteJid: 'status@broadcast',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'Status update' },
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores messages with no content', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-4',
|
||||
remoteJid: 'registered@g.us',
|
||||
fromMe: false,
|
||||
},
|
||||
message: null,
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts text from extendedTextMessage', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-5',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
extendedTextMessage: { text: 'A reply message' },
|
||||
},
|
||||
pushName: 'Charlie',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: 'A reply message' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts caption from imageMessage', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-6',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
imageMessage: { caption: 'Check this photo', mimetype: 'image/jpeg' },
|
||||
},
|
||||
pushName: 'Diana',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: 'Check this photo' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts caption from videoMessage', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-7',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
|
||||
},
|
||||
pushName: 'Eve',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: 'Watch this' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('transcribes voice messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-8',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
|
||||
},
|
||||
pushName: 'Frank',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(transcribeAudioMessage).toHaveBeenCalled();
|
||||
expect(opts.onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: '[Voice: Hello this is a voice message]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when transcription returns null', async () => {
|
||||
vi.mocked(transcribeAudioMessage).mockResolvedValueOnce(null);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-8b',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
|
||||
},
|
||||
pushName: 'Frank',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: '[Voice Message - transcription unavailable]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when transcription throws', async () => {
|
||||
vi.mocked(transcribeAudioMessage).mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-8c',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
|
||||
},
|
||||
pushName: 'Frank',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ content: '[Voice Message - transcription failed]' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses sender JID when pushName is absent', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-9',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'No push name' },
|
||||
// pushName is undefined
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.objectContaining({ sender_name: '5551234' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- LID ↔ JID translation ---
|
||||
|
||||
describe('LID to JID translation', () => {
|
||||
it('translates known LID to phone JID', async () => {
|
||||
const opts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'1234567890@s.whatsapp.net': {
|
||||
name: 'Self Chat',
|
||||
folder: 'self-chat',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
|
||||
// Send a message from the LID
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-lid',
|
||||
remoteJid: '9876543210@lid',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'From LID' },
|
||||
pushName: 'Self',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
// Should be translated to phone JID
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'1234567890@s.whatsapp.net',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'whatsapp',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through non-LID JIDs unchanged', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-normal',
|
||||
remoteJid: 'registered@g.us',
|
||||
participant: '5551234@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'Normal JID' },
|
||||
pushName: 'Grace',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'registered@g.us',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through unknown LID JIDs unchanged', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await triggerMessages([
|
||||
{
|
||||
key: {
|
||||
id: 'msg-unknown-lid',
|
||||
remoteJid: '0000000000@lid',
|
||||
fromMe: false,
|
||||
},
|
||||
message: { conversation: 'Unknown LID' },
|
||||
pushName: 'Unknown',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
// Unknown LID passes through unchanged
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'0000000000@lid',
|
||||
expect.any(String),
|
||||
undefined,
|
||||
'whatsapp',
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outgoing message queue ---
|
||||
|
||||
describe('outgoing message queue', () => {
|
||||
it('sends message directly when connected', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.sendMessage('test@g.us', 'Hello');
|
||||
// Group messages get prefixed with assistant name
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' });
|
||||
});
|
||||
|
||||
it('prefixes direct chat messages on shared number', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.sendMessage('123@s.whatsapp.net', 'Hello');
|
||||
// Shared number: DMs also get prefixed (needed for self-chat distinction)
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' });
|
||||
});
|
||||
|
||||
it('queues message when disconnected', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
// Don't connect — channel starts disconnected
|
||||
await channel.sendMessage('test@g.us', 'Queued');
|
||||
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues message on send failure', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Make sendMessage fail
|
||||
fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await channel.sendMessage('test@g.us', 'Will fail');
|
||||
|
||||
// Should not throw, message queued for retry
|
||||
// The queue should have the message
|
||||
});
|
||||
|
||||
it('flushes multiple queued messages in order', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
// Queue messages while disconnected
|
||||
await channel.sendMessage('test@g.us', 'First');
|
||||
await channel.sendMessage('test@g.us', 'Second');
|
||||
await channel.sendMessage('test@g.us', 'Third');
|
||||
|
||||
// Connect — flush happens automatically on open
|
||||
await connectChannel(channel);
|
||||
|
||||
// Give the async flush time to complete
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
|
||||
// Group messages get prefixed
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Group metadata sync ---
|
||||
|
||||
describe('group metadata sync', () => {
|
||||
it('syncs group metadata on first connection', async () => {
|
||||
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
|
||||
'group1@g.us': { subject: 'Group One' },
|
||||
'group2@g.us': { subject: 'Group Two' },
|
||||
});
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Wait for async sync to complete
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
|
||||
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
|
||||
expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
|
||||
expect(setLastGroupSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips sync when synced recently', async () => {
|
||||
// Last sync was 1 hour ago (within 24h threshold)
|
||||
vi.mocked(getLastGroupSync).mockReturnValue(
|
||||
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces sync regardless of cache', async () => {
|
||||
vi.mocked(getLastGroupSync).mockReturnValue(
|
||||
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
);
|
||||
|
||||
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
|
||||
'group@g.us': { subject: 'Forced Group' },
|
||||
});
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.syncGroupMetadata(true);
|
||||
|
||||
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
|
||||
expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
|
||||
});
|
||||
|
||||
it('handles group sync failure gracefully', async () => {
|
||||
fakeSocket.groupFetchAllParticipating.mockRejectedValue(
|
||||
new Error('Network timeout'),
|
||||
);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Should not throw
|
||||
await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips groups with no subject', async () => {
|
||||
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
|
||||
'group1@g.us': { subject: 'Has Subject' },
|
||||
'group2@g.us': { subject: '' },
|
||||
'group3@g.us': {},
|
||||
});
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
// Clear any calls from the automatic sync on connect
|
||||
vi.mocked(updateChatName).mockClear();
|
||||
|
||||
await channel.syncGroupMetadata(true);
|
||||
|
||||
expect(updateChatName).toHaveBeenCalledTimes(1);
|
||||
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
|
||||
});
|
||||
});
|
||||
|
||||
// --- JID ownership ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('owns @g.us JIDs (WhatsApp groups)', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(true);
|
||||
});
|
||||
|
||||
it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not own Telegram JIDs', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect(channel.ownsJid('tg:12345')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own unknown JID formats', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Typing indicator ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends composing presence when typing', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.setTyping('test@g.us', true);
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('composing', 'test@g.us');
|
||||
});
|
||||
|
||||
it('sends paused presence when stopping', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.setTyping('test@g.us', false);
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('paused', 'test@g.us');
|
||||
});
|
||||
|
||||
it('handles typing indicator failure gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
|
||||
await connectChannel(channel);
|
||||
|
||||
fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
// Should not throw
|
||||
await expect(channel.setTyping('test@g.us', true)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
it('has name "whatsapp"', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect(channel.name).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('does not expose prefixAssistantName (prefix handled internally)', () => {
|
||||
const channel = new WhatsAppChannel(createTestOpts());
|
||||
expect('prefixAssistantName' in channel).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
# Intent: src/channels/whatsapp.test.ts modifications
|
||||
|
||||
## What changed
|
||||
Added mock for the transcription module and 3 new test cases for voice message handling.
|
||||
|
||||
## Key sections
|
||||
|
||||
### Mocks (top of file)
|
||||
- Added: `vi.mock('../transcription.js', ...)` with `isVoiceMessage` and `transcribeAudioMessage` mocks
|
||||
- Added: `import { transcribeAudioMessage } from '../transcription.js'` for test assertions
|
||||
|
||||
### Test cases (inside "message handling" describe block)
|
||||
- Changed: "handles message with no extractable text (e.g. voice note without caption)" → "transcribes voice messages"
|
||||
- Now expects `[Voice: Hello this is a voice message]` instead of empty content
|
||||
- Added: "falls back when transcription returns null" — expects `[Voice Message - transcription unavailable]`
|
||||
- Added: "falls back when transcription throws" — expects `[Voice Message - transcription failed]`
|
||||
|
||||
## Invariants (must-keep)
|
||||
- All existing test cases for text, extendedTextMessage, imageMessage, videoMessage unchanged
|
||||
- All connection lifecycle tests unchanged
|
||||
- All LID translation tests unchanged
|
||||
- All outgoing queue tests unchanged
|
||||
- All group metadata sync tests unchanged
|
||||
- All ownsJid and setTyping tests unchanged
|
||||
- All existing mocks (config, logger, db, fs, child_process, baileys) unchanged
|
||||
- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel) unchanged
|
||||
@@ -1,356 +0,0 @@
|
||||
import { exec } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import makeWASocket, {
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
WASocket,
|
||||
fetchLatestWaWebVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, STORE_DIR } from '../config.js';
|
||||
import {
|
||||
getLastGroupSync,
|
||||
setLastGroupSync,
|
||||
updateChatName,
|
||||
} from '../db.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js';
|
||||
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
|
||||
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface WhatsAppChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class WhatsAppChannel implements Channel {
|
||||
name = 'whatsapp';
|
||||
|
||||
private sock!: WASocket;
|
||||
private connected = false;
|
||||
private lidToPhoneMap: Record<string, string> = {};
|
||||
private outgoingQueue: Array<{ jid: string; text: string }> = [];
|
||||
private flushing = false;
|
||||
private groupSyncTimerStarted = false;
|
||||
|
||||
private opts: WhatsAppChannelOpts;
|
||||
|
||||
constructor(opts: WhatsAppChannelOpts) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.connectInternal(resolve).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
|
||||
const authDir = path.join(STORE_DIR, 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||
logger.warn({ err }, 'Failed to fetch latest WA Web version, using default');
|
||||
return { version: undefined };
|
||||
});
|
||||
this.sock = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
});
|
||||
|
||||
this.sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
const msg =
|
||||
'WhatsApp authentication required. Run /setup in Claude Code.';
|
||||
logger.error(msg);
|
||||
exec(
|
||||
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
|
||||
);
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
this.connected = false;
|
||||
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed');
|
||||
|
||||
if (shouldReconnect) {
|
||||
logger.info('Reconnecting...');
|
||||
this.connectInternal().catch((err) => {
|
||||
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
|
||||
setTimeout(() => {
|
||||
this.connectInternal().catch((err2) => {
|
||||
logger.error({ err: err2 }, 'Reconnection retry failed');
|
||||
});
|
||||
}, 5000);
|
||||
});
|
||||
} else {
|
||||
logger.info('Logged out. Run /setup to re-authenticate.');
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
this.connected = true;
|
||||
logger.info('Connected to WhatsApp');
|
||||
|
||||
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
|
||||
this.sock.sendPresenceUpdate('available').catch((err) => {
|
||||
logger.warn({ err }, 'Failed to send presence update');
|
||||
});
|
||||
|
||||
// Build LID to phone mapping from auth state for self-chat translation
|
||||
if (this.sock.user) {
|
||||
const phoneUser = this.sock.user.id.split(':')[0];
|
||||
const lidUser = this.sock.user.lid?.split(':')[0];
|
||||
if (lidUser && phoneUser) {
|
||||
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
|
||||
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any messages queued while disconnected
|
||||
this.flushOutgoingQueue().catch((err) =>
|
||||
logger.error({ err }, 'Failed to flush outgoing queue'),
|
||||
);
|
||||
|
||||
// Sync group metadata on startup (respects 24h cache)
|
||||
this.syncGroupMetadata().catch((err) =>
|
||||
logger.error({ err }, 'Initial group sync failed'),
|
||||
);
|
||||
// Set up daily sync timer (only once)
|
||||
if (!this.groupSyncTimerStarted) {
|
||||
this.groupSyncTimerStarted = true;
|
||||
setInterval(() => {
|
||||
this.syncGroupMetadata().catch((err) =>
|
||||
logger.error({ err }, 'Periodic group sync failed'),
|
||||
);
|
||||
}, GROUP_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Signal first connection to caller
|
||||
if (onFirstOpen) {
|
||||
onFirstOpen();
|
||||
onFirstOpen = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
this.sock.ev.on('messages.upsert', async ({ messages }) => {
|
||||
for (const msg of messages) {
|
||||
if (!msg.message) continue;
|
||||
const rawJid = msg.key.remoteJid;
|
||||
if (!rawJid || rawJid === 'status@broadcast') continue;
|
||||
|
||||
// Translate LID JID to phone JID if applicable
|
||||
const chatJid = await this.translateJid(rawJid);
|
||||
|
||||
const timestamp = new Date(
|
||||
Number(msg.messageTimestamp) * 1000,
|
||||
).toISOString();
|
||||
|
||||
// Always notify about chat metadata for group discovery
|
||||
const isGroup = chatJid.endsWith('@g.us');
|
||||
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'whatsapp', isGroup);
|
||||
|
||||
// Only deliver full message for registered groups
|
||||
const groups = this.opts.registeredGroups();
|
||||
if (groups[chatJid]) {
|
||||
const content =
|
||||
msg.message?.conversation ||
|
||||
msg.message?.extendedTextMessage?.text ||
|
||||
msg.message?.imageMessage?.caption ||
|
||||
msg.message?.videoMessage?.caption ||
|
||||
'';
|
||||
|
||||
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
|
||||
// but allow voice messages through for transcription
|
||||
if (!content && !isVoiceMessage(msg)) continue;
|
||||
|
||||
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||
const senderName = msg.pushName || sender.split('@')[0];
|
||||
|
||||
const fromMe = msg.key.fromMe || false;
|
||||
// Detect bot messages: with own number, fromMe is reliable
|
||||
// since only the bot sends from that number.
|
||||
// With shared number, bot messages carry the assistant name prefix
|
||||
// (even in DMs/self-chat) so we check for that.
|
||||
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
|
||||
? fromMe
|
||||
: content.startsWith(`${ASSISTANT_NAME}:`);
|
||||
|
||||
// Transcribe voice messages before storing
|
||||
let finalContent = content;
|
||||
if (isVoiceMessage(msg)) {
|
||||
try {
|
||||
const transcript = await transcribeAudioMessage(msg, this.sock);
|
||||
if (transcript) {
|
||||
finalContent = `[Voice: ${transcript}]`;
|
||||
logger.info({ chatJid, length: transcript.length }, 'Transcribed voice message');
|
||||
} else {
|
||||
finalContent = '[Voice Message - transcription unavailable]';
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Voice transcription error');
|
||||
finalContent = '[Voice Message - transcription failed]';
|
||||
}
|
||||
}
|
||||
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msg.key.id || '',
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content: finalContent,
|
||||
timestamp,
|
||||
is_from_me: fromMe,
|
||||
is_bot_message: isBotMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
// Prefix bot messages with assistant name so users know who's speaking.
|
||||
// On a shared number, prefix is also needed in DMs (including self-chat)
|
||||
// to distinguish bot output from user messages.
|
||||
// Skip only when the assistant has its own dedicated phone number.
|
||||
const prefixed = ASSISTANT_HAS_OWN_NUMBER
|
||||
? text
|
||||
: `${ASSISTANT_NAME}: ${text}`;
|
||||
|
||||
if (!this.connected) {
|
||||
this.outgoingQueue.push({ jid, text: prefixed });
|
||||
logger.info({ jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.sock.sendMessage(jid, { text: prefixed });
|
||||
logger.info({ jid, length: prefixed.length }, 'Message sent');
|
||||
} catch (err) {
|
||||
// If send fails, queue it for retry on reconnect
|
||||
this.outgoingQueue.push({ jid, text: prefixed });
|
||||
logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
this.sock?.end(undefined);
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
try {
|
||||
const status = isTyping ? 'composing' : 'paused';
|
||||
logger.debug({ jid, status }, 'Sending presence update');
|
||||
await this.sock.sendPresenceUpdate(status, jid);
|
||||
} catch (err) {
|
||||
logger.debug({ jid, err }, 'Failed to update typing status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync group metadata from WhatsApp.
|
||||
* Fetches all participating groups and stores their names in the database.
|
||||
* Called on startup, daily, and on-demand via IPC.
|
||||
*/
|
||||
async syncGroupMetadata(force = false): Promise<void> {
|
||||
if (!force) {
|
||||
const lastSync = getLastGroupSync();
|
||||
if (lastSync) {
|
||||
const lastSyncTime = new Date(lastSync).getTime();
|
||||
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
|
||||
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Syncing group metadata from WhatsApp...');
|
||||
const groups = await this.sock.groupFetchAllParticipating();
|
||||
|
||||
let count = 0;
|
||||
for (const [jid, metadata] of Object.entries(groups)) {
|
||||
if (metadata.subject) {
|
||||
updateChatName(jid, metadata.subject);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
setLastGroupSync();
|
||||
logger.info({ count }, 'Group metadata synced');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to sync group metadata');
|
||||
}
|
||||
}
|
||||
|
||||
private async translateJid(jid: string): Promise<string> {
|
||||
if (!jid.endsWith('@lid')) return jid;
|
||||
const lidUser = jid.split('@')[0].split(':')[0];
|
||||
|
||||
// Check local cache first
|
||||
const cached = this.lidToPhoneMap[lidUser];
|
||||
if (cached) {
|
||||
logger.debug({ lidJid: jid, phoneJid: cached }, 'Translated LID to phone JID (cached)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Query Baileys' signal repository for the mapping
|
||||
try {
|
||||
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
|
||||
if (pn) {
|
||||
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||
this.lidToPhoneMap[lidUser] = phoneJid;
|
||||
logger.info({ lidJid: jid, phoneJid }, 'Translated LID to phone JID (signalRepository)');
|
||||
return phoneJid;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
|
||||
}
|
||||
|
||||
return jid;
|
||||
}
|
||||
|
||||
private async flushOutgoingQueue(): Promise<void> {
|
||||
if (this.flushing || this.outgoingQueue.length === 0) return;
|
||||
this.flushing = true;
|
||||
try {
|
||||
logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue');
|
||||
while (this.outgoingQueue.length > 0) {
|
||||
const item = this.outgoingQueue.shift()!;
|
||||
// Send directly — queued items are already prefixed by sendMessage
|
||||
await this.sock.sendMessage(item.jid, { text: item.text });
|
||||
logger.info({ jid: item.jid, length: item.text.length }, 'Queued message sent');
|
||||
}
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Intent: src/channels/whatsapp.ts modifications
|
||||
|
||||
## What changed
|
||||
Added voice message transcription support. When a WhatsApp voice note (PTT audio) arrives, it is downloaded and transcribed via OpenAI Whisper before being stored as message content.
|
||||
|
||||
## Key sections
|
||||
|
||||
### Imports (top of file)
|
||||
- Added: `isVoiceMessage`, `transcribeAudioMessage` from `../transcription.js`
|
||||
|
||||
### messages.upsert handler (inside connectInternal)
|
||||
- Added: `let finalContent = content` variable to allow voice transcription to override text content
|
||||
- Added: `isVoiceMessage(msg)` check after content extraction
|
||||
- Added: try/catch block calling `transcribeAudioMessage(msg, this.sock)`
|
||||
- Success: `finalContent = '[Voice: <transcript>]'`
|
||||
- Null result: `finalContent = '[Voice Message - transcription unavailable]'`
|
||||
- Error: `finalContent = '[Voice Message - transcription failed]'`
|
||||
- Changed: `this.opts.onMessage()` call uses `finalContent` instead of `content`
|
||||
|
||||
## Invariants (must-keep)
|
||||
- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage) unchanged
|
||||
- Connection lifecycle (connect, reconnect, disconnect) unchanged
|
||||
- LID translation logic unchanged
|
||||
- Outgoing message queue unchanged
|
||||
- Group metadata sync unchanged
|
||||
- sendMessage prefix logic unchanged
|
||||
- setTyping, ownsJid, isConnected — all unchanged
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('voice-transcription 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: voice-transcription');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('openai');
|
||||
expect(content).toContain('OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
it('has all files declared in adds', () => {
|
||||
const transcriptionFile = path.join(skillDir, 'add', 'src', 'transcription.ts');
|
||||
expect(fs.existsSync(transcriptionFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(transcriptionFile, 'utf-8');
|
||||
expect(content).toContain('transcribeAudioMessage');
|
||||
expect(content).toContain('isVoiceMessage');
|
||||
expect(content).toContain('transcribeWithOpenAI');
|
||||
expect(content).toContain('downloadMediaMessage');
|
||||
expect(content).toContain('readEnvFile');
|
||||
});
|
||||
|
||||
it('has all files declared in modifies', () => {
|
||||
const whatsappFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts');
|
||||
const whatsappTestFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts');
|
||||
|
||||
expect(fs.existsSync(whatsappFile)).toBe(true);
|
||||
expect(fs.existsSync(whatsappTestFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('has intent files for modified files', () => {
|
||||
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('modified whatsapp.ts preserves core structure', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Core class and methods preserved
|
||||
expect(content).toContain('class WhatsAppChannel');
|
||||
expect(content).toContain('implements Channel');
|
||||
expect(content).toContain('async connect()');
|
||||
expect(content).toContain('async sendMessage(');
|
||||
expect(content).toContain('isConnected()');
|
||||
expect(content).toContain('ownsJid(');
|
||||
expect(content).toContain('async disconnect()');
|
||||
expect(content).toContain('async setTyping(');
|
||||
expect(content).toContain('async syncGroupMetadata(');
|
||||
expect(content).toContain('private async translateJid(');
|
||||
expect(content).toContain('private async flushOutgoingQueue(');
|
||||
|
||||
// Core imports preserved
|
||||
expect(content).toContain('ASSISTANT_HAS_OWN_NUMBER');
|
||||
expect(content).toContain('ASSISTANT_NAME');
|
||||
expect(content).toContain('STORE_DIR');
|
||||
});
|
||||
|
||||
it('modified whatsapp.ts includes transcription integration', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Transcription imports
|
||||
expect(content).toContain("import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'");
|
||||
|
||||
// Voice message handling
|
||||
expect(content).toContain('isVoiceMessage(msg)');
|
||||
expect(content).toContain('transcribeAudioMessage(msg, this.sock)');
|
||||
expect(content).toContain('finalContent');
|
||||
expect(content).toContain('[Voice:');
|
||||
expect(content).toContain('[Voice Message - transcription unavailable]');
|
||||
expect(content).toContain('[Voice Message - transcription failed]');
|
||||
});
|
||||
|
||||
it('modified whatsapp.test.ts includes transcription mock and tests', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Transcription mock
|
||||
expect(content).toContain("vi.mock('../transcription.js'");
|
||||
expect(content).toContain('isVoiceMessage');
|
||||
expect(content).toContain('transcribeAudioMessage');
|
||||
|
||||
// Voice transcription test cases
|
||||
expect(content).toContain('transcribes voice messages');
|
||||
expect(content).toContain('falls back when transcription returns null');
|
||||
expect(content).toContain('falls back when transcription throws');
|
||||
expect(content).toContain('[Voice: Hello this is a voice message]');
|
||||
});
|
||||
|
||||
it('modified whatsapp.test.ts preserves all existing test sections', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// All existing test describe blocks preserved
|
||||
expect(content).toContain("describe('connection lifecycle'");
|
||||
expect(content).toContain("describe('authentication'");
|
||||
expect(content).toContain("describe('reconnection'");
|
||||
expect(content).toContain("describe('message handling'");
|
||||
expect(content).toContain("describe('LID to JID translation'");
|
||||
expect(content).toContain("describe('outgoing message queue'");
|
||||
expect(content).toContain("describe('group metadata sync'");
|
||||
expect(content).toContain("describe('ownsJid'");
|
||||
expect(content).toContain("describe('setTyping'");
|
||||
expect(content).toContain("describe('channel properties'");
|
||||
});
|
||||
});
|
||||
372
.claude/skills/add-whatsapp/SKILL.md
Normal file
372
.claude/skills/add-whatsapp/SKILL.md
Normal file
@@ -0,0 +1,372 @@
|
||||
---
|
||||
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? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication).
|
||||
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp main
|
||||
git merge whatsapp/main || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/whatsapp.test.ts` (41 unit tests)
|
||||
- `src/whatsapp-auth.ts` (standalone WhatsApp authentication script)
|
||||
- `setup/whatsapp-auth.ts` (WhatsApp auth setup step)
|
||||
- `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- `'whatsapp-auth'` step added to `setup/index.ts`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json`
|
||||
- `ASSISTANT_HAS_OWN_NUMBER` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/channels/whatsapp.test.ts
|
||||
```
|
||||
|
||||
All tests must pass and 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:
|
||||
|
||||
Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
|
||||
|
||||
Run the auth process in the background and poll `store/pairing-code.txt` for the code:
|
||||
|
||||
```bash
|
||||
rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
|
||||
```
|
||||
|
||||
Then immediately poll for the code (do NOT wait for the background command to finish):
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
|
||||
```
|
||||
|
||||
Display the code to the user the moment it appears. Tell them:
|
||||
|
||||
> **Enter this code now** — it expires in ~60 seconds.
|
||||
>
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Tap **Link with phone number instead**
|
||||
> 3. Enter the code immediately
|
||||
|
||||
After the user enters the code, poll for authentication to complete:
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
|
||||
```
|
||||
|
||||
**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 "<jid>" \
|
||||
--name "<chat-name>" \
|
||||
--trigger "@<trigger>" \
|
||||
--folder "whatsapp_main" \
|
||||
--channel whatsapp \
|
||||
--assistant-name "<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 "<group-jid>" \
|
||||
--name "<group-name>" \
|
||||
--trigger "@<trigger>" \
|
||||
--folder "whatsapp_<group-name>" \
|
||||
--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 <phone>
|
||||
```
|
||||
|
||||
Enter the code **immediately** when it appears. Also ensure:
|
||||
1. Phone number is digits only — country code + number, no `+` prefix (e.g., `14155551234` where `1` is country code, `4155551234` is the number)
|
||||
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)
|
||||
137
.claude/skills/channel-formatting/SKILL.md
Normal file
137
.claude/skills/channel-formatting/SKILL.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: channel-formatting
|
||||
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
|
||||
---
|
||||
|
||||
# Channel Formatting
|
||||
|
||||
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
|
||||
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
|
||||
Telegram.
|
||||
|
||||
| Channel | Transformation |
|
||||
|---------|---------------|
|
||||
| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links → `text (url)` |
|
||||
| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) |
|
||||
| Slack | same as WhatsApp, but links become `<url\|text>` |
|
||||
| Discord | passthrough (Discord already renders Markdown) |
|
||||
| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill |
|
||||
|
||||
Code blocks (fenced and inline) are always protected — their content is never transformed.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
test -f src/text-styles.ts && echo "already applied" || echo "not yet applied"
|
||||
```
|
||||
|
||||
If `already applied`, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/channel-formatting
|
||||
git merge upstream/skill/channel-formatting
|
||||
```
|
||||
|
||||
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
|
||||
version and continuing:
|
||||
|
||||
```bash
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
```
|
||||
|
||||
For any other conflict, read the conflicted file and reconcile both sides manually.
|
||||
|
||||
This merge adds:
|
||||
|
||||
- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and
|
||||
`parseSignalStyles(text)` for Signal native rich text
|
||||
- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided
|
||||
it calls `parseTextStyles` after stripping `<internal>` tags
|
||||
- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound`
|
||||
- `src/formatting.test.ts` — test coverage for both functions across all channels
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/formatting.test.ts
|
||||
```
|
||||
|
||||
All 73 tests should pass and the build should be clean before continuing.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Rebuild and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Spot-check formatting
|
||||
|
||||
Send a message through any registered WhatsApp or Telegram chat that will trigger a
|
||||
response from Claude. Ask something that will produce formatted output, such as:
|
||||
|
||||
> Summarise the three main advantages of TypeScript using bullet points and **bold** headings.
|
||||
|
||||
Confirm that the response arrives with native bold (`*text*`) rather than raw double
|
||||
asterisks.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Signal Skill Integration
|
||||
|
||||
If you have the Signal skill installed, `src/channels/signal.ts` can import
|
||||
`parseSignalStyles` from the newly present `src/text-styles.ts`:
|
||||
|
||||
```typescript
|
||||
import { parseSignalStyles, SignalTextStyle } from '../text-styles.js';
|
||||
```
|
||||
|
||||
`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where
|
||||
`textStyle` is an array of `{ style, start, length }` objects suitable for the
|
||||
`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`).
|
||||
|
||||
## Removal
|
||||
|
||||
```bash
|
||||
# Remove the new file
|
||||
rm src/text-styles.ts
|
||||
|
||||
# Revert router.ts to remove the channel param
|
||||
git diff upstream/main src/router.ts # review changes
|
||||
git checkout upstream/main -- src/router.ts
|
||||
|
||||
# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText)
|
||||
# (edit manually or: git checkout upstream/main -- src/index.ts)
|
||||
|
||||
npm run build
|
||||
```
|
||||
131
.claude/skills/claw/SKILL.md
Normal file
131
.claude/skills/claw/SKILL.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: claw
|
||||
description: Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app.
|
||||
---
|
||||
|
||||
# claw — NanoClaw CLI
|
||||
|
||||
`claw` is a Python CLI that sends prompts directly to a NanoClaw agent container from the terminal. It reads registered groups from the NanoClaw database, picks up secrets from `.env`, and pipes a JSON payload into a container run — no chat app required.
|
||||
|
||||
## What it does
|
||||
|
||||
- Send a prompt to any registered group by name, folder, or JID
|
||||
- Default target is the main group (no `-g` needed for most use)
|
||||
- Resume a previous session with `-s <session-id>`
|
||||
- Read prompts from stdin (`--pipe`) for scripting and piping
|
||||
- List all registered groups with `--list-groups`
|
||||
- Auto-detects `container` or `docker` runtime (or override with `--runtime`)
|
||||
- Prints the agent's response to stdout; session ID to stderr
|
||||
- Verbose mode (`-v`) shows the command, redacted payload, and exit code
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.8 or later
|
||||
- NanoClaw installed with a built and tagged container image (`nanoclaw-agent:latest`)
|
||||
- Either `container` (Apple Container, macOS 15+) or `docker` available in `PATH`
|
||||
|
||||
## Install
|
||||
|
||||
Run this skill from within the NanoClaw directory. The script auto-detects its location, so the symlink always points to the right place.
|
||||
|
||||
### 1. Copy the script
|
||||
|
||||
```bash
|
||||
mkdir -p scripts
|
||||
cp "${CLAUDE_SKILL_DIR}/scripts/claw" scripts/claw
|
||||
chmod +x scripts/claw
|
||||
```
|
||||
|
||||
### 2. Symlink into PATH
|
||||
|
||||
```bash
|
||||
mkdir -p ~/bin
|
||||
ln -sf "$(pwd)/scripts/claw" ~/bin/claw
|
||||
```
|
||||
|
||||
Make sure `~/bin` is in `PATH`. Add this to `~/.zshrc` or `~/.bashrc` if needed:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/bin:$PATH"
|
||||
```
|
||||
|
||||
Then reload the shell:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc # or ~/.bashrc
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
|
||||
```bash
|
||||
claw --list-groups
|
||||
```
|
||||
|
||||
You should see registered groups. If NanoClaw isn't running or the database doesn't exist yet, the list will be empty — that's fine.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Send a prompt to the main group
|
||||
claw "What's on my calendar today?"
|
||||
|
||||
# Send to a specific group by name (fuzzy match)
|
||||
claw -g "family" "Remind everyone about dinner at 7"
|
||||
|
||||
# Send to a group by exact JID
|
||||
claw -j "120363336345536173@g.us" "Hello"
|
||||
|
||||
# Resume a previous session
|
||||
claw -s abc123 "Continue where we left off"
|
||||
|
||||
# Read prompt from stdin
|
||||
echo "Summarize this" | claw --pipe -g dev
|
||||
|
||||
# Pipe a file
|
||||
cat report.txt | claw --pipe "Summarize this report"
|
||||
|
||||
# List all registered groups
|
||||
claw --list-groups
|
||||
|
||||
# Force a specific runtime
|
||||
claw --runtime docker "Hello"
|
||||
|
||||
# Use a custom image tag (e.g. after rebuilding with a new tag)
|
||||
claw --image nanoclaw-agent:dev "Hello"
|
||||
|
||||
# Verbose mode (debug info, secrets redacted)
|
||||
claw -v "Hello"
|
||||
|
||||
# Custom timeout for long-running tasks
|
||||
claw --timeout 600 "Run the full analysis"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "neither 'container' nor 'docker' found"
|
||||
|
||||
Install Docker Desktop or Apple Container (macOS 15+), or pass `--runtime` explicitly.
|
||||
|
||||
### "no secrets found in .env"
|
||||
|
||||
The script auto-detects your NanoClaw directory and reads `.env` from it. Check that the file exists and contains at least one of: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Container times out
|
||||
|
||||
The default timeout is 300 seconds. For longer tasks, pass `--timeout 600` (or higher). If the container consistently hangs, check that your `nanoclaw-agent:latest` image is up to date by running `./container/build.sh`.
|
||||
|
||||
### "group not found"
|
||||
|
||||
Run `claw --list-groups` to see what's registered. Group lookup does a fuzzy partial match on name and folder — if your query matches multiple groups, you'll get an error listing the ambiguous matches.
|
||||
|
||||
### Container crashes mid-stream
|
||||
|
||||
Containers run with `--rm` so they are automatically removed. If the agent crashes before emitting the output sentinel, `claw` falls back to printing raw stdout. Use `-v` to see what the container produced. Rebuild the image with `./container/build.sh` if crashes are consistent.
|
||||
|
||||
### Override the NanoClaw directory
|
||||
|
||||
If `claw` can't find your database or `.env`, set the `NANOCLAW_DIR` environment variable:
|
||||
|
||||
```bash
|
||||
export NANOCLAW_DIR=/path/to/your/nanoclaw
|
||||
```
|
||||
374
.claude/skills/claw/scripts/claw
Normal file
374
.claude/skills/claw/scripts/claw
Normal file
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
claw — NanoClaw CLI
|
||||
Run a NanoClaw agent container from the command line.
|
||||
|
||||
Usage:
|
||||
claw "What is 2+2?"
|
||||
claw -g <channel_name> "Review this code"
|
||||
claw -g "<channel name with spaces>" "What's the latest issue?"
|
||||
claw -j "<chatJid>" "Hello"
|
||||
claw -g <channel_name> -s <session-id> "Continue"
|
||||
claw --list-groups
|
||||
echo "prompt text" | claw --pipe -g <channel_name>
|
||||
cat prompt.txt | claw --pipe
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# ── Globals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
def dbg(*args):
|
||||
if VERBOSE:
|
||||
print("»", *args, file=sys.stderr)
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_nanoclaw_dir() -> Path:
|
||||
"""Locate the NanoClaw installation directory.
|
||||
|
||||
Resolution order:
|
||||
1. NANOCLAW_DIR env var
|
||||
2. The directory containing this script (if it looks like a NanoClaw install)
|
||||
3. ~/src/nanoclaw (legacy default)
|
||||
"""
|
||||
if env := os.environ.get("NANOCLAW_DIR"):
|
||||
return Path(env).expanduser()
|
||||
# If this script lives inside the NanoClaw tree (e.g. scripts/claw), walk up
|
||||
here = Path(__file__).resolve()
|
||||
for parent in [here.parent, here.parent.parent]:
|
||||
if (parent / "store" / "messages.db").exists() or (parent / ".env").exists():
|
||||
return parent
|
||||
return Path.home() / "src" / "nanoclaw"
|
||||
|
||||
NANOCLAW_DIR = _find_nanoclaw_dir()
|
||||
DB_PATH = NANOCLAW_DIR / "store" / "messages.db"
|
||||
ENV_FILE = NANOCLAW_DIR / ".env"
|
||||
IMAGE = "nanoclaw-agent:latest"
|
||||
|
||||
SECRET_KEYS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"OLLAMA_HOST",
|
||||
]
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_runtime(preference: str | None) -> str:
|
||||
if preference:
|
||||
dbg(f"runtime: forced to {preference}")
|
||||
return preference
|
||||
for rt in ("container", "docker"):
|
||||
result = subprocess.run(["which", rt], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
dbg(f"runtime: auto-detected {rt} at {result.stdout.decode().strip()}")
|
||||
return rt
|
||||
sys.exit("error: neither 'container' nor 'docker' found. Install one or pass --runtime.")
|
||||
|
||||
|
||||
def read_secrets(env_file: Path) -> dict:
|
||||
secrets = {}
|
||||
if not env_file.exists():
|
||||
return secrets
|
||||
for line in env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
if key in SECRET_KEYS:
|
||||
secrets[key] = val.strip()
|
||||
return secrets
|
||||
|
||||
|
||||
def get_groups(db: Path) -> list[dict]:
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute(
|
||||
"SELECT jid, name, folder, is_main FROM registered_groups ORDER BY name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [{"jid": r[0], "name": r[1], "folder": r[2], "is_main": bool(r[3])} for r in rows]
|
||||
|
||||
|
||||
def find_group(groups: list[dict], query: str) -> dict | None:
|
||||
q = query.lower()
|
||||
# Exact name match
|
||||
for g in groups:
|
||||
if g["name"].lower() == q or g["folder"].lower() == q:
|
||||
return g
|
||||
# Partial match
|
||||
matches = [g for g in groups if q in g["name"].lower() or q in g["folder"].lower()]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
names = ", ".join(f'"{g["name"]}"' for g in matches)
|
||||
sys.exit(f"error: ambiguous group '{query}'. Matches: {names}")
|
||||
return None
|
||||
|
||||
|
||||
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (host_path, container_path, readonly) tuples."""
|
||||
groups_dir = NANOCLAW_DIR / "groups"
|
||||
data_dir = NANOCLAW_DIR / "data"
|
||||
sessions_dir = data_dir / "sessions" / folder
|
||||
ipc_dir = data_dir / "ipc" / folder
|
||||
|
||||
# Ensure required dirs exist
|
||||
group_dir = groups_dir / folder
|
||||
group_dir.mkdir(parents=True, exist_ok=True)
|
||||
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
|
||||
for sub in ("messages", "tasks", "input"):
|
||||
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
agent_runner_src = sessions_dir / "agent-runner-src"
|
||||
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
|
||||
if not agent_runner_src.exists() and project_agent_runner.exists():
|
||||
import shutil
|
||||
shutil.copytree(project_agent_runner, agent_runner_src)
|
||||
|
||||
mounts: list[tuple[str, str, bool]] = []
|
||||
if is_main:
|
||||
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
|
||||
mounts.append((str(group_dir), "/workspace/group", False))
|
||||
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
|
||||
mounts.append((str(ipc_dir), "/workspace/ipc", False))
|
||||
if agent_runner_src.exists():
|
||||
mounts.append((str(agent_runner_src), "/app/src", False))
|
||||
return mounts
|
||||
|
||||
|
||||
def run_container(runtime: str, image: str, payload: dict,
|
||||
folder: str | None = None, is_main: bool = False,
|
||||
timeout: int = 300) -> None:
|
||||
cmd = [runtime, "run", "-i", "--rm"]
|
||||
if folder:
|
||||
for host, container, readonly in build_mounts(folder, is_main):
|
||||
if readonly:
|
||||
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
|
||||
else:
|
||||
cmd += ["-v", f"{host}:{container}"]
|
||||
cmd.append(image)
|
||||
dbg(f"cmd: {' '.join(cmd)}")
|
||||
|
||||
# Show payload sans secrets
|
||||
if VERBOSE:
|
||||
safe = {k: v for k, v in payload.items() if k != "secrets"}
|
||||
safe["secrets"] = {k: "***" for k in payload.get("secrets", {})}
|
||||
dbg(f"payload: {json.dumps(safe, indent=2)}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
dbg(f"container pid: {proc.pid}")
|
||||
|
||||
# Write JSON payload and close stdin
|
||||
proc.stdin.write(json.dumps(payload).encode())
|
||||
proc.stdin.close()
|
||||
dbg("stdin closed, waiting for response...")
|
||||
|
||||
stdout_lines: list[str] = []
|
||||
stderr_lines: list[str] = []
|
||||
done = threading.Event()
|
||||
|
||||
def stream_stderr():
|
||||
for raw in proc.stderr:
|
||||
line = raw.decode(errors="replace").rstrip()
|
||||
if line.startswith("npm notice"):
|
||||
continue
|
||||
stderr_lines.append(line)
|
||||
print(line, file=sys.stderr)
|
||||
|
||||
def stream_stdout():
|
||||
for raw in proc.stdout:
|
||||
line = raw.decode(errors="replace").rstrip()
|
||||
stdout_lines.append(line)
|
||||
dbg(f"stdout: {line}")
|
||||
# Kill the container as soon as we see the closing sentinel —
|
||||
# the Node.js event loop often keeps the process alive indefinitely.
|
||||
if line.strip() == "---NANOCLAW_OUTPUT_END---":
|
||||
dbg("output sentinel found, terminating container")
|
||||
done.set()
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
dbg("graceful stop timed out, force killing container")
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return
|
||||
|
||||
t_err = threading.Thread(target=stream_stderr, daemon=True)
|
||||
t_out = threading.Thread(target=stream_stdout, daemon=True)
|
||||
t_err.start()
|
||||
t_out.start()
|
||||
|
||||
# Wait for sentinel or timeout
|
||||
if not done.wait(timeout=timeout):
|
||||
# Also check if process exited naturally
|
||||
t_out.join(timeout=2)
|
||||
if not done.is_set():
|
||||
proc.kill()
|
||||
sys.exit(f"error: container timed out after {timeout}s (no output sentinel received)")
|
||||
|
||||
t_err.join(timeout=5)
|
||||
t_out.join(timeout=5)
|
||||
proc.wait()
|
||||
dbg(f"container done (rc={proc.returncode}), {len(stdout_lines)} stdout lines")
|
||||
stdout = "\n".join(stdout_lines)
|
||||
|
||||
# Parse output block
|
||||
match = re.search(
|
||||
r"---NANOCLAW_OUTPUT_START---\n(.*?)\n---NANOCLAW_OUTPUT_END---",
|
||||
stdout,
|
||||
re.DOTALL,
|
||||
)
|
||||
success = False
|
||||
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
status = data.get("status", "unknown")
|
||||
if status == "success":
|
||||
print(data.get("result", ""))
|
||||
session_id = data.get("newSessionId") or data.get("sessionId")
|
||||
if session_id:
|
||||
print(f"\n[session: {session_id}]", file=sys.stderr)
|
||||
success = True
|
||||
else:
|
||||
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print(match.group(1))
|
||||
else:
|
||||
# No structured output — print raw stdout
|
||||
print(stdout)
|
||||
|
||||
if success:
|
||||
return
|
||||
|
||||
if proc.returncode not in (0, None):
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="claw",
|
||||
description="Run a NanoClaw agent from the command line.",
|
||||
)
|
||||
parser.add_argument("prompt", nargs="?", help="Prompt to send")
|
||||
parser.add_argument("-g", "--group", help="Group name or folder (fuzzy match)")
|
||||
parser.add_argument("-j", "--jid", help="Chat JID (exact)")
|
||||
parser.add_argument("-s", "--session", help="Session ID to resume")
|
||||
parser.add_argument("-p", "--pipe", action="store_true",
|
||||
help="Read prompt from stdin (can be combined with a prompt arg as prefix)")
|
||||
parser.add_argument("--runtime", choices=["docker", "container"],
|
||||
help="Container runtime (default: auto-detect)")
|
||||
parser.add_argument("--image", default=IMAGE, help=f"Container image (default: {IMAGE})")
|
||||
parser.add_argument("--list-groups", action="store_true", help="List registered groups and exit")
|
||||
parser.add_argument("--raw", action="store_true", help="Print raw JSON output")
|
||||
parser.add_argument("--timeout", type=int, default=300, metavar="SECS",
|
||||
help="Max seconds to wait for a response (default: 300)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Show debug info: cmd, payload (secrets redacted), stdout lines, exit code")
|
||||
args = parser.parse_args()
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = args.verbose
|
||||
|
||||
groups = get_groups(DB_PATH) if DB_PATH.exists() else []
|
||||
|
||||
if args.list_groups:
|
||||
print(f"{'NAME':<35} {'FOLDER':<30} {'JID'}")
|
||||
print("-" * 100)
|
||||
for g in groups:
|
||||
main_tag = " [main]" if g["is_main"] else ""
|
||||
print(f"{g['name']:<35} {g['folder']:<30} {g['jid']}{main_tag}")
|
||||
return
|
||||
|
||||
# Resolve prompt: --pipe reads stdin, optionally prepended with positional arg
|
||||
if args.pipe or (not sys.stdin.isatty() and not args.prompt):
|
||||
stdin_text = sys.stdin.read().strip()
|
||||
if args.prompt:
|
||||
prompt = f"{args.prompt}\n\n{stdin_text}"
|
||||
else:
|
||||
prompt = stdin_text
|
||||
else:
|
||||
prompt = args.prompt
|
||||
|
||||
if not prompt:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve group → jid
|
||||
jid = args.jid
|
||||
group_name = None
|
||||
group_folder = None
|
||||
is_main = False
|
||||
|
||||
if args.group:
|
||||
g = find_group(groups, args.group)
|
||||
if g is None:
|
||||
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
|
||||
jid = g["jid"]
|
||||
group_name = g["name"]
|
||||
group_folder = g["folder"]
|
||||
is_main = g["is_main"]
|
||||
elif not jid:
|
||||
# Default: main group
|
||||
mains = [g for g in groups if g["is_main"]]
|
||||
if mains:
|
||||
jid = mains[0]["jid"]
|
||||
group_name = mains[0]["name"]
|
||||
group_folder = mains[0]["folder"]
|
||||
is_main = True
|
||||
else:
|
||||
sys.exit("error: no group specified and no main group found. Use -g or -j.")
|
||||
|
||||
runtime = detect_runtime(args.runtime)
|
||||
secrets = read_secrets(ENV_FILE)
|
||||
|
||||
if not secrets:
|
||||
print("warning: no secrets found in .env — agent may not be authenticated", file=sys.stderr)
|
||||
|
||||
payload: dict = {
|
||||
"prompt": prompt,
|
||||
"chatJid": jid,
|
||||
"isMain": is_main,
|
||||
"secrets": secrets,
|
||||
}
|
||||
if group_name:
|
||||
payload["groupFolder"] = group_name
|
||||
if args.session:
|
||||
payload["sessionId"] = args.session
|
||||
payload["resumeAt"] = "latest"
|
||||
|
||||
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
|
||||
run_container(runtime, args.image, payload,
|
||||
folder=group_folder, is_main=is_main,
|
||||
timeout=args.timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,11 +13,13 @@ This skill switches NanoClaw's container runtime from Docker to Apple Container
|
||||
- Startup check: `docker info` → `container system status` (with auto-start)
|
||||
- Orphan detection: `docker ps --filter` → `container ls --format json`
|
||||
- Build script default: `docker` → `container`
|
||||
- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay)
|
||||
- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv`
|
||||
|
||||
**What stays the same:**
|
||||
- Dockerfile (shared by both runtimes)
|
||||
- Container runner code (`src/container-runner.ts`)
|
||||
- Mount security/allowlist validation
|
||||
- All exported interfaces and IPC protocol
|
||||
- Non-main container behavior (still uses `--user` flag)
|
||||
- All other functionality
|
||||
|
||||
## Prerequisites
|
||||
@@ -39,10 +41,6 @@ Apple Container requires macOS. It does not work on Linux.
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `convert-to-apple-container` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place.
|
||||
|
||||
### Check current runtime
|
||||
|
||||
```bash
|
||||
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
|
||||
```
|
||||
@@ -51,33 +49,33 @@ If it already shows `'container'`, the runtime is already Apple Container. Skip
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
|
||||
|
||||
### Apply the skill
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/convert-to-apple-container
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Replaces `src/container-runtime.ts` with the Apple Container implementation
|
||||
- Replaces `src/container-runtime.test.ts` with Apple Container-specific tests
|
||||
- Updates `container/build.sh` to default to `container` runtime
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
### Merge the skill branch
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
- `modify/src/container-runtime.ts.intent.md` — what changed and invariants
|
||||
- `modify/container/build.sh.intent.md` — what changed for build script
|
||||
```bash
|
||||
git fetch upstream skill/apple-container
|
||||
git merge upstream/skill/apple-container
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
|
||||
- `src/container-runtime.test.ts` — Apple Container-specific tests
|
||||
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
|
||||
- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind`
|
||||
- `container/build.sh` — default runtime set to `container`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
@@ -172,4 +170,6 @@ Check directory permissions on the host. The container runs as uid 1000.
|
||||
|------|----------------|
|
||||
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
|
||||
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
|
||||
| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop |
|
||||
| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop |
|
||||
| `container/build.sh` | Default runtime: `docker` → `container` |
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
skill: convert-to-apple-container
|
||||
version: 1.0.0
|
||||
description: "Switch container runtime from Docker to Apple Container (macOS)"
|
||||
core_version: 0.1.0
|
||||
adds: []
|
||||
modifies:
|
||||
- src/container-runtime.ts
|
||||
- src/container-runtime.test.ts
|
||||
- container/build.sh
|
||||
structured: {}
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npx vitest run src/container-runtime.test.ts"
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the NanoClaw agent container image
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
IMAGE_NAME="nanoclaw-agent"
|
||||
TAG="${1:-latest}"
|
||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
|
||||
|
||||
echo "Building NanoClaw agent container image..."
|
||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||
|
||||
${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" .
|
||||
|
||||
echo ""
|
||||
echo "Build complete!"
|
||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}"
|
||||
@@ -1,17 +0,0 @@
|
||||
# Intent: container/build.sh modifications
|
||||
|
||||
## What changed
|
||||
Changed the default container runtime from `docker` to `container` (Apple Container CLI).
|
||||
|
||||
## Key sections
|
||||
- `CONTAINER_RUNTIME` default: `docker` → `container`
|
||||
- All build/run commands use `${CONTAINER_RUNTIME}` variable (unchanged)
|
||||
|
||||
## Invariants
|
||||
- The `CONTAINER_RUNTIME` environment variable override still works
|
||||
- IMAGE_NAME and TAG logic unchanged
|
||||
- Build and test echo commands unchanged
|
||||
|
||||
## Must-keep
|
||||
- The `CONTAINER_RUNTIME` env var override pattern
|
||||
- The test command echo at the end
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child_process — store the mock fn so tests can configure it
|
||||
const mockExecSync = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
ensureContainerRuntimeRunning,
|
||||
cleanupOrphans,
|
||||
} from './container-runtime.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Pure functions ---
|
||||
|
||||
describe('readonlyMountArgs', () => {
|
||||
it('returns --mount flag with type=bind and readonly', () => {
|
||||
const args = readonlyMountArgs('/host/path', '/container/path');
|
||||
expect(args).toEqual([
|
||||
'--mount',
|
||||
'type=bind,source=/host/path,target=/container/path,readonly',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopContainer', () => {
|
||||
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
|
||||
expect(stopContainer('nanoclaw-test-123')).toBe(
|
||||
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- ensureContainerRuntimeRunning ---
|
||||
|
||||
describe('ensureContainerRuntimeRunning', () => {
|
||||
it('does nothing when runtime is already running', () => {
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
ensureContainerRuntimeRunning();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
`${CONTAINER_RUNTIME_BIN} system status`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
||||
});
|
||||
|
||||
it('auto-starts when system status fails', () => {
|
||||
// First call (system status) fails
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('not running');
|
||||
});
|
||||
// Second call (system start) succeeds
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
ensureContainerRuntimeRunning();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${CONTAINER_RUNTIME_BIN} system start`,
|
||||
{ stdio: 'pipe', timeout: 30000 },
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
|
||||
});
|
||||
|
||||
it('throws when both status and start fail', () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('failed');
|
||||
});
|
||||
|
||||
expect(() => ensureContainerRuntimeRunning()).toThrow(
|
||||
'Container runtime is required but failed to start',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- cleanupOrphans ---
|
||||
|
||||
describe('cleanupOrphans', () => {
|
||||
it('stops orphaned nanoclaw containers from JSON output', () => {
|
||||
// Apple Container ls returns JSON
|
||||
const lsOutput = JSON.stringify([
|
||||
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
|
||||
{ status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } },
|
||||
{ status: 'running', configuration: { id: 'nanoclaw-group3-333' } },
|
||||
{ status: 'running', configuration: { id: 'other-container' } },
|
||||
]);
|
||||
mockExecSync.mockReturnValueOnce(lsOutput);
|
||||
// stop calls succeed
|
||||
mockExecSync.mockReturnValue('');
|
||||
|
||||
cleanupOrphans();
|
||||
|
||||
// ls + 2 stop calls (only running nanoclaw- containers)
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no orphans exist', () => {
|
||||
mockExecSync.mockReturnValueOnce('[]');
|
||||
|
||||
cleanupOrphans();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and continues when ls fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('container not available');
|
||||
});
|
||||
|
||||
cleanupOrphans(); // should not throw
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to clean up orphaned containers',
|
||||
);
|
||||
});
|
||||
|
||||
it('continues stopping remaining containers when one stop fails', () => {
|
||||
const lsOutput = JSON.stringify([
|
||||
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
|
||||
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
|
||||
]);
|
||||
mockExecSync.mockReturnValueOnce(lsOutput);
|
||||
// First stop fails
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('already stopped');
|
||||
});
|
||||
// Second stop succeeds
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
cleanupOrphans(); // should not throw
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Container runtime abstraction for NanoClaw.
|
||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** The container runtime binary name. */
|
||||
export const CONTAINER_RUNTIME_BIN = 'container';
|
||||
|
||||
/** Returns CLI args for a readonly bind mount. */
|
||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
||||
}
|
||||
|
||||
/** Returns the shell command to stop a container by name. */
|
||||
export function stopContainer(name: string): string {
|
||||
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
|
||||
}
|
||||
|
||||
/** Ensure the container runtime is running, starting it if needed. */
|
||||
export function ensureContainerRuntimeRunning(): void {
|
||||
try {
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
|
||||
logger.debug('Container runtime already running');
|
||||
} catch {
|
||||
logger.info('Starting container runtime...');
|
||||
try {
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
|
||||
logger.info('Container runtime started');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to start container runtime');
|
||||
console.error(
|
||||
'\n╔════════════════════════════════════════════════════════════════╗',
|
||||
);
|
||||
console.error(
|
||||
'║ FATAL: Container runtime failed to start ║',
|
||||
);
|
||||
console.error(
|
||||
'║ ║',
|
||||
);
|
||||
console.error(
|
||||
'║ Agents cannot run without a container runtime. To fix: ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 1. Ensure Apple Container is installed ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 2. Run: container system start ║',
|
||||
);
|
||||
console.error(
|
||||
'║ 3. Restart NanoClaw ║',
|
||||
);
|
||||
console.error(
|
||||
'╚════════════════════════════════════════════════════════════════╝\n',
|
||||
);
|
||||
throw new Error('Container runtime is required but failed to start');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill orphaned NanoClaw containers from previous runs. */
|
||||
export function cleanupOrphans(): void {
|
||||
try {
|
||||
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
|
||||
const orphans = containers
|
||||
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
|
||||
.map((c) => c.configuration.id);
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||
} catch { /* already stopped */ }
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# Intent: src/container-runtime.ts modifications
|
||||
|
||||
## What changed
|
||||
Replaced Docker runtime with Apple Container runtime. This is a full file replacement — the exported API is identical, only the implementation differs.
|
||||
|
||||
## Key sections
|
||||
|
||||
### CONTAINER_RUNTIME_BIN
|
||||
- Changed: `'docker'` → `'container'` (the Apple Container CLI binary)
|
||||
|
||||
### readonlyMountArgs
|
||||
- Changed: Docker `-v host:container:ro` → Apple Container `--mount type=bind,source=...,target=...,readonly`
|
||||
|
||||
### ensureContainerRuntimeRunning
|
||||
- Changed: `docker info` → `container system status` for checking
|
||||
- Added: auto-start via `container system start` when not running (Apple Container supports this; Docker requires manual start)
|
||||
- Changed: error message references Apple Container instead of Docker
|
||||
|
||||
### cleanupOrphans
|
||||
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'` → `container ls --format json` with JSON parsing
|
||||
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
|
||||
|
||||
## Invariants
|
||||
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
|
||||
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
|
||||
- Logger usage pattern is unchanged
|
||||
- Error handling pattern is unchanged
|
||||
|
||||
## Must-keep
|
||||
- The exported function signatures (consumed by container-runner.ts and index.ts)
|
||||
- The error box-drawing output format
|
||||
- The orphan cleanup logic (find + stop pattern)
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('convert-to-apple-container 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: convert-to-apple-container');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('container-runtime.ts');
|
||||
expect(content).toContain('container/build.sh');
|
||||
});
|
||||
|
||||
it('has all modified files', () => {
|
||||
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
|
||||
expect(fs.existsSync(runtimeFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(runtimeFile, 'utf-8');
|
||||
expect(content).toContain("CONTAINER_RUNTIME_BIN = 'container'");
|
||||
expect(content).toContain('system status');
|
||||
expect(content).toContain('system start');
|
||||
expect(content).toContain('ls --format json');
|
||||
|
||||
const testFile = path.join(skillDir, 'modify', 'src', 'container-runtime.test.ts');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain('system status');
|
||||
expect(testContent).toContain('--mount');
|
||||
});
|
||||
|
||||
it('has intent files for modified sources', () => {
|
||||
const runtimeIntent = path.join(skillDir, 'modify', 'src', 'container-runtime.ts.intent.md');
|
||||
expect(fs.existsSync(runtimeIntent)).toBe(true);
|
||||
|
||||
const buildIntent = path.join(skillDir, 'modify', 'container', 'build.sh.intent.md');
|
||||
expect(fs.existsSync(buildIntent)).toBe(true);
|
||||
});
|
||||
|
||||
it('has build.sh with Apple Container default', () => {
|
||||
const buildFile = path.join(skillDir, 'modify', 'container', 'build.sh');
|
||||
expect(fs.existsSync(buildFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(buildFile, 'utf-8');
|
||||
expect(content).toContain('CONTAINER_RUNTIME:-container');
|
||||
expect(content).not.toContain('CONTAINER_RUNTIME:-docker');
|
||||
});
|
||||
|
||||
it('uses Apple Container API patterns (not Docker)', () => {
|
||||
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
|
||||
const content = fs.readFileSync(runtimeFile, 'utf-8');
|
||||
|
||||
// Apple Container patterns
|
||||
expect(content).toContain('system status');
|
||||
expect(content).toContain('system start');
|
||||
expect(content).toContain('ls --format json');
|
||||
expect(content).toContain('type=bind,source=');
|
||||
|
||||
// Should NOT contain Docker patterns
|
||||
expect(content).not.toContain('docker info');
|
||||
expect(content).not.toContain("'-v'");
|
||||
expect(content).not.toContain('--filter name=');
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,9 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
|
||||
## Workflow
|
||||
|
||||
1. **Understand the request** - Ask clarifying questions
|
||||
2. **Plan the changes** - Identify files to modify
|
||||
3. **Implement** - Make changes directly to the code
|
||||
4. **Test guidance** - Tell user how to verify
|
||||
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
|
||||
4. **Implement** - Make changes directly to the code
|
||||
5. **Test guidance** - Tell user how to verify
|
||||
|
||||
## Key Files
|
||||
|
||||
|
||||
276
.claude/skills/init-onecli/SKILL.md
Normal file
276
.claude/skills/init-onecli/SKILL.md
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
name: init-onecli
|
||||
description: Install and initialize OneCLI Agent Vault. Migrates existing .env credentials to the vault. Use after /update-nanoclaw brings in OneCLI as a breaking change, or for first-time OneCLI setup.
|
||||
---
|
||||
|
||||
# Initialize OneCLI Agent Vault
|
||||
|
||||
This skill installs OneCLI, configures the Agent Vault gateway, and migrates any existing `.env` credentials into it. Run this after `/update-nanoclaw` introduces OneCLI as a breaking change, or any time OneCLI needs to be set up from scratch.
|
||||
|
||||
**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. pasting a token).
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if OneCLI is already working
|
||||
|
||||
```bash
|
||||
onecli version 2>/dev/null
|
||||
```
|
||||
|
||||
If the command succeeds, OneCLI is installed. Check if the gateway is reachable:
|
||||
|
||||
```bash
|
||||
curl -sf http://127.0.0.1:10254/health
|
||||
```
|
||||
|
||||
If both succeed, check for an Anthropic secret:
|
||||
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
If an Anthropic secret exists, tell the user OneCLI is already configured and working. Use AskUserQuestion:
|
||||
|
||||
1. **Keep current setup** — description: "OneCLI is installed and has credentials configured. Nothing to do."
|
||||
2. **Reconfigure** — description: "Start fresh — reinstall OneCLI and re-register credentials."
|
||||
|
||||
If they choose to keep, skip to Phase 5 (Verify). If they choose to reconfigure, continue.
|
||||
|
||||
### Check for native credential proxy
|
||||
|
||||
```bash
|
||||
grep "credential-proxy" src/index.ts 2>/dev/null
|
||||
```
|
||||
|
||||
If `startCredentialProxy` is imported, the native credential proxy skill is active. Tell the user: "You're currently using the native credential proxy (`.env`-based). This skill will switch you to OneCLI's Agent Vault, which adds per-agent policies and rate limits. Your `.env` credentials will be migrated to the vault."
|
||||
|
||||
Use AskUserQuestion:
|
||||
1. **Continue** — description: "Switch to OneCLI Agent Vault."
|
||||
2. **Cancel** — description: "Keep the native credential proxy."
|
||||
|
||||
If they cancel, stop.
|
||||
|
||||
### Check the codebase expects OneCLI
|
||||
|
||||
```bash
|
||||
grep "@onecli-sh/sdk" package.json
|
||||
```
|
||||
|
||||
If `@onecli-sh/sdk` is NOT in package.json, the codebase hasn't been updated to use OneCLI yet. Tell the user to run `/update-nanoclaw` first to get the OneCLI integration, then retry `/init-onecli`. Stop here.
|
||||
|
||||
## Phase 2: Install OneCLI
|
||||
|
||||
### Install the gateway and CLI
|
||||
|
||||
```bash
|
||||
curl -fsSL onecli.sh/install | sh
|
||||
curl -fsSL onecli.sh/cli/install | sh
|
||||
```
|
||||
|
||||
Verify: `onecli version`
|
||||
|
||||
If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
||||
```
|
||||
|
||||
Re-verify with `onecli version`.
|
||||
|
||||
### Configure the CLI
|
||||
|
||||
Point the CLI at the local OneCLI instance:
|
||||
|
||||
```bash
|
||||
onecli config set api-host http://127.0.0.1:10254
|
||||
```
|
||||
|
||||
### Set ONECLI_URL in .env
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
|
||||
```
|
||||
|
||||
### Wait for gateway readiness
|
||||
|
||||
The gateway may take a moment to start after installation. Poll for up to 15 seconds:
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf http://127.0.0.1:10254/health && break
|
||||
sleep 1
|
||||
done
|
||||
```
|
||||
|
||||
If it never becomes healthy, check if the gateway process is running:
|
||||
|
||||
```bash
|
||||
ps aux | grep -i onecli | grep -v grep
|
||||
```
|
||||
|
||||
If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
|
||||
|
||||
## Phase 3: Migrate existing credentials
|
||||
|
||||
### Scan .env for credentials to migrate
|
||||
|
||||
Read the `.env` file and look for these credential variables:
|
||||
|
||||
| .env variable | OneCLI secret type | Host pattern |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | `anthropic` | `api.anthropic.com` |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
|
||||
| `ANTHROPIC_AUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
|
||||
|
||||
Read `.env`:
|
||||
|
||||
```bash
|
||||
cat .env
|
||||
```
|
||||
|
||||
Parse the file for any of the credential variables listed above.
|
||||
|
||||
### If credentials found in .env
|
||||
|
||||
For each credential found, migrate it to OneCLI:
|
||||
|
||||
**Anthropic API key** (`ANTHROPIC_API_KEY=sk-ant-...`):
|
||||
```bash
|
||||
onecli secrets create --name Anthropic --type anthropic --value <key> --host-pattern api.anthropic.com
|
||||
```
|
||||
|
||||
**Claude OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN=...` or `ANTHROPIC_AUTH_TOKEN=...`):
|
||||
```bash
|
||||
onecli secrets create --name Anthropic --type anthropic --value <token> --host-pattern api.anthropic.com
|
||||
```
|
||||
|
||||
After successful migration, remove the credential lines from `.env`. Use the Edit tool to remove only the credential variable lines (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`). Keep all other `.env` entries intact (e.g. `ONECLI_URL`, `TELEGRAM_BOT_TOKEN`, channel tokens).
|
||||
|
||||
Verify the secret was registered:
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers."
|
||||
|
||||
### Offer to migrate other container-facing credentials
|
||||
|
||||
After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for remaining credential variables that containers use for outbound API calls.
|
||||
|
||||
**Important:** Only migrate credentials that containers use via outbound HTTPS. Channel tokens (`TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DISCORD_BOT_TOKEN`) are used by the NanoClaw host process to connect to messaging platforms — they must stay in `.env`.
|
||||
|
||||
Known container-facing credentials:
|
||||
|
||||
| .env variable | Secret name | Host pattern |
|
||||
|---|---|---|
|
||||
| `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` |
|
||||
| `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` |
|
||||
|
||||
If any of these are found with non-empty values, present them to the user:
|
||||
|
||||
AskUserQuestion (multiSelect): "These credentials are used by container agents for outbound API calls. Moving them to the vault means agents never see the raw keys, and you can apply rate limits and policies."
|
||||
|
||||
- One option per credential found (e.g., "OPENAI_API_KEY" — description: "Used by voice transcription and other OpenAI integrations inside containers")
|
||||
- **Skip — keep them in .env** — description: "Leave these in .env for now. You can move them later."
|
||||
|
||||
For each credential the user selects:
|
||||
|
||||
```bash
|
||||
onecli secrets create --name <SecretName> --type api_key --value <value> --host-pattern <host>
|
||||
```
|
||||
|
||||
If there are credential variables not in the table above that look container-facing (i.e. not a channel token), ask the user: "Is `<VARIABLE_NAME>` used by agents inside containers? If so, what API host does it authenticate against? (e.g., `api.example.com`)" — then migrate accordingly.
|
||||
|
||||
After migration, remove the migrated lines from `.env` using the Edit tool. Keep channel tokens and any credentials the user chose not to migrate.
|
||||
|
||||
Verify all secrets were registered:
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
### If no credentials found in .env
|
||||
|
||||
No migration needed. Proceed to register credentials fresh.
|
||||
|
||||
Check if OneCLI already has an Anthropic secret:
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
If an Anthropic secret already exists, skip to Phase 4.
|
||||
|
||||
Otherwise, register credentials using the same flow as `/setup`:
|
||||
|
||||
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
|
||||
|
||||
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
|
||||
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
|
||||
|
||||
#### Subscription path
|
||||
|
||||
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
|
||||
|
||||
Once they have the token, AskUserQuestion with two options:
|
||||
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
|
||||
|
||||
#### API key path
|
||||
|
||||
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
|
||||
|
||||
AskUserQuestion with two options:
|
||||
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
|
||||
|
||||
#### After either path
|
||||
|
||||
Ask them to let you know when done.
|
||||
|
||||
**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
|
||||
|
||||
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
|
||||
|
||||
## Phase 4: Build and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `npm install` first.
|
||||
|
||||
Restart the service:
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux (systemd): `systemctl --user restart nanoclaw`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
Check logs for successful OneCLI integration:
|
||||
|
||||
```bash
|
||||
tail -30 logs/nanoclaw.log | grep -i "onecli\|gateway"
|
||||
```
|
||||
|
||||
Expected: `OneCLI gateway config applied` messages when containers start.
|
||||
|
||||
If the service is running and a channel is configured, tell the user to send a test message to verify the agent responds.
|
||||
|
||||
Tell the user:
|
||||
- OneCLI Agent Vault is now managing credentials
|
||||
- Agents never see raw API keys — credentials are injected at the gateway level
|
||||
- To manage secrets: `onecli secrets list`, or open http://127.0.0.1:10254
|
||||
- To add rate limits or policies: `onecli rules create --help`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf http://127.0.0.1:10254/health`. Start it with `onecli start` if needed.
|
||||
|
||||
**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`).
|
||||
|
||||
**Old .env credentials still present:** This skill should have removed them. Double-check `.env` for `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_AUTH_TOKEN` and remove them manually if still present.
|
||||
|
||||
**Port 10254 already in use:** Another OneCLI instance may be running. Check with `lsof -i :10254` and kill the old process, or configure a different port.
|
||||
@@ -1,17 +1,56 @@
|
||||
---
|
||||
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 <name>` 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 <name>` 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.
|
||||
|
||||
## 1. Bootstrap (Node.js + Dependencies)
|
||||
## 0. Git & Fork Setup
|
||||
|
||||
Check the git remote configuration to ensure the user has a fork and upstream is configured.
|
||||
|
||||
Run:
|
||||
- `git remote -v`
|
||||
|
||||
**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):**
|
||||
|
||||
The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?"
|
||||
- Fork now (recommended) — walk them through it
|
||||
- Continue without fork — they'll only have local changes
|
||||
|
||||
If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run:
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
git remote add origin https://github.com/<their-username>/nanoclaw.git
|
||||
git push --force origin main
|
||||
```
|
||||
Verify with `git remote -v`.
|
||||
|
||||
If continue without fork: add upstream so they can still pull updates:
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
**Case B — `origin` points to user's fork, no `upstream` remote:**
|
||||
|
||||
Add upstream:
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
**Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:**
|
||||
|
||||
Already configured. Continue.
|
||||
|
||||
**Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`.
|
||||
|
||||
## 1. Bootstrap (Node.js + Dependencies + OneCLI)
|
||||
|
||||
Run `bash setup.sh` and parse the status block.
|
||||
|
||||
@@ -19,18 +58,53 @@ Run `bash setup.sh` and parse the status block.
|
||||
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
|
||||
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
|
||||
- After installing Node, re-run `bash setup.sh`
|
||||
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules` and `package-lock.json`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
|
||||
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
|
||||
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
|
||||
- Record PLATFORM and IS_WSL for later steps.
|
||||
|
||||
After bootstrap succeeds, install OneCLI and its CLI tool:
|
||||
|
||||
```bash
|
||||
curl -fsSL onecli.sh/install | sh
|
||||
curl -fsSL onecli.sh/cli/install | sh
|
||||
```
|
||||
|
||||
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Persist for future sessions (append to shell profile if not already present)
|
||||
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
||||
```
|
||||
|
||||
Then re-verify with `onecli version`.
|
||||
|
||||
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
|
||||
```bash
|
||||
onecli config set api-host http://127.0.0.1:10254
|
||||
```
|
||||
|
||||
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
|
||||
```bash
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
|
||||
```
|
||||
|
||||
## 2. Check Environment
|
||||
|
||||
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
|
||||
|
||||
## 2a. Timezone
|
||||
|
||||
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
|
||||
|
||||
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz <their-answer>`.
|
||||
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
|
||||
|
||||
## 3. Container Runtime
|
||||
|
||||
### 3a. Choose runtime
|
||||
@@ -38,12 +112,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 3c.
|
||||
- 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
|
||||
@@ -61,7 +135,7 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
|
||||
|
||||
**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
|
||||
|
||||
**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 3c.
|
||||
|
||||
### 3c. Build and test
|
||||
|
||||
@@ -73,63 +147,88 @@ Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse th
|
||||
|
||||
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
|
||||
|
||||
## 4. Claude Authentication (No Script)
|
||||
## 4. Anthropic Credentials via OneCLI
|
||||
|
||||
If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure?
|
||||
NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time.
|
||||
|
||||
AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
|
||||
Check if a secret already exists:
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat.
|
||||
If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5.
|
||||
|
||||
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
|
||||
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
|
||||
|
||||
## 5. WhatsApp Authentication
|
||||
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
|
||||
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
|
||||
|
||||
If HAS_AUTH=true, confirm: keep or re-authenticate?
|
||||
### Subscription path
|
||||
|
||||
**Choose auth method based on environment (from step 2):**
|
||||
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
|
||||
|
||||
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?
|
||||
Once they have the token, they register it with OneCLI. AskUserQuestion with two options:
|
||||
|
||||
- **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.
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
|
||||
|
||||
**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
|
||||
### API key path
|
||||
|
||||
## 6. Configure Trigger and Channel Type
|
||||
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
|
||||
|
||||
Get bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
|
||||
Then AskUserQuestion with two options:
|
||||
|
||||
AskUserQuestion: Shared number or dedicated? → AskUserQuestion: Trigger word? → AskUserQuestion: Main channel type?
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
|
||||
|
||||
**Shared number:** Self-chat (recommended) or Solo group
|
||||
**Dedicated number:** DM with bot (recommended) or Solo group with bot
|
||||
### After either path
|
||||
|
||||
## 7. Sync and Select Group (If Group Channel)
|
||||
Ask them to let you know when done.
|
||||
|
||||
**Personal chat:** JID = `NUMBER@s.whatsapp.net`
|
||||
**DM with bot:** Ask for bot's number, JID = `NUMBER@s.whatsapp.net`
|
||||
**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
|
||||
|
||||
**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).
|
||||
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
|
||||
|
||||
## 8. Register Channel
|
||||
## 5. Set Up Channels
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
## 9. Mount Allowlist
|
||||
**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.
|
||||
|
||||
For each selected channel, invoke its skill:
|
||||
|
||||
- **WhatsApp:** Invoke `/add-whatsapp`
|
||||
- **Telegram:** Invoke `/add-telegram`
|
||||
- **Slack:** Invoke `/add-slack`
|
||||
- **Discord:** Invoke `/add-discord`
|
||||
|
||||
Each skill will:
|
||||
1. Install the channel code (via `git merge` of the skill branch)
|
||||
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
|
||||
|
||||
**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages:
|
||||
|
||||
```bash
|
||||
npm install && npm run build
|
||||
```
|
||||
|
||||
If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6.
|
||||
|
||||
## 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 +258,34 @@ 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
|
||||
- CREDENTIALS=missing → re-run step 4
|
||||
- WHATSAPP_AUTH=not_found → re-run step 5
|
||||
- REGISTERED_GROUPS=0 → re-run steps 7-8
|
||||
- SERVICE=not_found → re-run step 7
|
||||
- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret)
|
||||
- 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), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), 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`
|
||||
|
||||
|
||||
## 9. Diagnostics
|
||||
|
||||
1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
|
||||
2. Follow every step in that file before completing setup.
|
||||
|
||||
46
.claude/skills/setup/diagnostics.md
Normal file
46
.claude/skills/setup/diagnostics.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Diagnostics
|
||||
|
||||
Gather system info:
|
||||
|
||||
```bash
|
||||
node -p "require('./package.json').version"
|
||||
uname -s
|
||||
uname -m
|
||||
node -p "process.versions.node.split('.')[0]"
|
||||
```
|
||||
|
||||
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
|
||||
"event": "setup_complete",
|
||||
"distinct_id": "<uuid>",
|
||||
"properties": {
|
||||
"success": true,
|
||||
"nanoclaw_version": "1.2.21",
|
||||
"os_platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"channels_selected": ["telegram", "whatsapp"],
|
||||
"error_count": 0,
|
||||
"failed_step": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
|
||||
|
||||
**Yes**:
|
||||
```bash
|
||||
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
|
||||
rm /tmp/nanoclaw-diagnostics.json
|
||||
```
|
||||
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
|
||||
4. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
241
.claude/skills/update-nanoclaw/SKILL.md
Normal file
241
.claude/skills/update-nanoclaw/SKILL.md
Normal file
@@ -0,0 +1,241 @@
|
||||
---
|
||||
name: update-nanoclaw
|
||||
description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage.
|
||||
---
|
||||
|
||||
# About
|
||||
|
||||
Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications.
|
||||
|
||||
Run `/update-nanoclaw` in Claude Code.
|
||||
|
||||
## How it works
|
||||
|
||||
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
|
||||
|
||||
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
|
||||
|
||||
**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
|
||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
|
||||
- **Source** (`src/`): may conflict if you modified the same files
|
||||
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
|
||||
|
||||
**Update paths** (you pick one):
|
||||
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
|
||||
- `cherry-pick`: `git cherry-pick <hashes>`. Pull in only the commits you want.
|
||||
- `rebase`: `git rebase upstream/<branch>`. Linear history, but conflicts resolve per-commit.
|
||||
- `abort`: just view the changelog, change nothing.
|
||||
|
||||
**Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point.
|
||||
|
||||
**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
|
||||
|
||||
**Validation**: runs `npm run build` and `npm test`.
|
||||
|
||||
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
|
||||
|
||||
## Rollback
|
||||
|
||||
The backup tag is printed at the end of each run:
|
||||
```
|
||||
git reset --hard pre-update-<hash>-<timestamp>
|
||||
```
|
||||
|
||||
Backup branch `backup/pre-update-<hash>-<timestamp>` also exists.
|
||||
|
||||
## Token usage
|
||||
|
||||
Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git status` for everything else. Does not scan or refactor unrelated code.
|
||||
|
||||
---
|
||||
|
||||
# Goal
|
||||
Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens.
|
||||
|
||||
# Operating principles
|
||||
- Never proceed with a dirty working tree.
|
||||
- Always create a rollback point (backup branch + tag) before touching anything.
|
||||
- Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers.
|
||||
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
|
||||
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
|
||||
|
||||
# Step 0: Preflight (stop early if unsafe)
|
||||
Run:
|
||||
- `git status --porcelain`
|
||||
If output is non-empty:
|
||||
- Tell the user to commit or stash first, then stop.
|
||||
|
||||
Confirm remotes:
|
||||
- `git remote -v`
|
||||
If `upstream` is missing:
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
|
||||
- Add it: `git remote add upstream <user-provided-url>`
|
||||
- Then: `git fetch upstream --prune`
|
||||
|
||||
Determine the upstream branch name:
|
||||
- `git branch -r | grep upstream/`
|
||||
- If `upstream/main` exists, use `main`.
|
||||
- If only `upstream/master` exists, use `master`.
|
||||
- Otherwise, ask the user which branch to use.
|
||||
- Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead.
|
||||
|
||||
Fetch:
|
||||
- `git fetch upstream --prune`
|
||||
|
||||
# Step 1: Create a safety net
|
||||
Capture current state:
|
||||
- `HASH=$(git rev-parse --short HEAD)`
|
||||
- `TIMESTAMP=$(date +%Y%m%d-%H%M%S)`
|
||||
|
||||
Create backup branch and tag (using timestamp to avoid collisions on retry):
|
||||
- `git branch backup/pre-update-$HASH-$TIMESTAMP`
|
||||
- `git tag pre-update-$HASH-$TIMESTAMP`
|
||||
|
||||
Save the tag name for later reference in the summary and rollback instructions.
|
||||
|
||||
# Step 2: Preview what upstream changed (no edits yet)
|
||||
Compute common base:
|
||||
- `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
|
||||
|
||||
Show upstream commits since BASE:
|
||||
- `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
|
||||
|
||||
Show local commits since BASE (custom drift):
|
||||
- `git log --oneline $BASE..HEAD`
|
||||
|
||||
Show file-level impact from upstream:
|
||||
- `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH`
|
||||
|
||||
Bucket the upstream changed files:
|
||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
|
||||
- **Source** (`src/`): may conflict if user modified the same files
|
||||
- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
|
||||
- **Other**: docs, tests, misc
|
||||
|
||||
Present these buckets to the user and ask them to choose one path using AskUserQuestion:
|
||||
- A) **Full update**: merge all upstream changes
|
||||
- B) **Selective update**: cherry-pick specific upstream commits
|
||||
- C) **Abort**: they only wanted the preview
|
||||
- D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit)
|
||||
|
||||
If Abort: stop here.
|
||||
|
||||
# Step 3: Conflict preview (before committing anything)
|
||||
If Full update or Rebase:
|
||||
- Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes:
|
||||
```
|
||||
git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort
|
||||
```
|
||||
- If conflicts were listed: show them and ask user if they want to proceed.
|
||||
- If no conflicts: tell user it is clean and proceed.
|
||||
|
||||
# Step 4A: Full update (MERGE, default)
|
||||
Run:
|
||||
- `git merge upstream/$UPSTREAM_BRANCH --no-edit`
|
||||
|
||||
If conflicts occur:
|
||||
- Run `git status` and identify conflicted files.
|
||||
- For each conflicted file:
|
||||
- Open the file.
|
||||
- Resolve only conflict markers.
|
||||
- Preserve intentional local customizations.
|
||||
- Incorporate upstream fixes/improvements.
|
||||
- Do not refactor surrounding code.
|
||||
- `git add <file>`
|
||||
- When all resolved:
|
||||
- If merge did not auto-commit: `git commit --no-edit`
|
||||
|
||||
# Step 4B: Selective update (CHERRY-PICK)
|
||||
If user chose Selective:
|
||||
- Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
|
||||
- Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
|
||||
- Ask user which commit hashes they want.
|
||||
- Apply: `git cherry-pick <hash1> <hash2> ...`
|
||||
|
||||
If conflicts during cherry-pick:
|
||||
- Resolve only conflict markers, then:
|
||||
- `git add <file>`
|
||||
- `git cherry-pick --continue`
|
||||
If user wants to stop:
|
||||
- `git cherry-pick --abort`
|
||||
|
||||
# Step 4C: Rebase (only if user explicitly chose option D)
|
||||
Run:
|
||||
- `git rebase upstream/$UPSTREAM_BRANCH`
|
||||
|
||||
If conflicts:
|
||||
- Resolve conflict markers only, then:
|
||||
- `git add <file>`
|
||||
- `git rebase --continue`
|
||||
If it gets messy (more than 3 rounds of conflicts):
|
||||
- `git rebase --abort`
|
||||
- Recommend merge instead.
|
||||
|
||||
# Step 5: Validation
|
||||
Run:
|
||||
- `npm run build`
|
||||
- `npm test` (do not fail the flow if tests are not configured)
|
||||
|
||||
If build fails:
|
||||
- Show the error.
|
||||
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
|
||||
- Do not refactor unrelated code.
|
||||
- If unclear, ask the user before making changes.
|
||||
|
||||
# Step 6: Breaking changes check
|
||||
After validation succeeds, check if the update introduced any breaking changes.
|
||||
|
||||
Determine which CHANGELOG entries are new by diffing against the backup tag:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
|
||||
|
||||
Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is:
|
||||
```
|
||||
[BREAKING] <description>. Run `/<skill-name>` to <action>.
|
||||
```
|
||||
|
||||
If no `[BREAKING]` lines are found:
|
||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
||||
|
||||
If one or more `[BREAKING]` lines are found:
|
||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
||||
- For each breaking change, display the full description.
|
||||
- Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part).
|
||||
- Use AskUserQuestion to ask the user which migration skills they want to run now. Options:
|
||||
- One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel")
|
||||
- "Skip — I'll handle these manually"
|
||||
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill updates
|
||||
After the summary, check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
- After the skill completes (or if user selected no), proceed to Step 8.
|
||||
|
||||
# Step 8: Summary + rollback instructions
|
||||
Show:
|
||||
- Backup tag: the tag name created in Step 1
|
||||
- New HEAD: `git rev-parse --short HEAD`
|
||||
- Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH`
|
||||
- Conflicts resolved (list files, if any)
|
||||
- Breaking changes applied (list skills run, if any)
|
||||
- Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD`
|
||||
|
||||
Tell the user:
|
||||
- To rollback: `git reset --hard <backup-tag-from-step-1>`
|
||||
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
|
||||
- Restart the service to apply changes:
|
||||
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
|
||||
- If running manually: restart `npm run dev`
|
||||
|
||||
|
||||
## Diagnostics
|
||||
|
||||
1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`.
|
||||
2. Follow every step in that file before finishing.
|
||||
49
.claude/skills/update-nanoclaw/diagnostics.md
Normal file
49
.claude/skills/update-nanoclaw/diagnostics.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Diagnostics
|
||||
|
||||
Gather system info:
|
||||
|
||||
```bash
|
||||
node -p "require('./package.json').version"
|
||||
uname -s
|
||||
uname -m
|
||||
node -p "process.versions.node.split('.')[0]"
|
||||
git log -1 --format=%ci HEAD@{1} 2>/dev/null || echo "unknown"
|
||||
```
|
||||
|
||||
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
|
||||
"event": "update_complete",
|
||||
"distinct_id": "<uuid>",
|
||||
"properties": {
|
||||
"success": true,
|
||||
"nanoclaw_version": "1.2.21",
|
||||
"os_platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"version_age_days": 45,
|
||||
"update_method": "merge",
|
||||
"conflict_count": 0,
|
||||
"breaking_changes_found": false,
|
||||
"error_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
|
||||
|
||||
**Yes**:
|
||||
```bash
|
||||
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
|
||||
rm /tmp/nanoclaw-diagnostics.json
|
||||
```
|
||||
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
|
||||
4. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
130
.claude/skills/update-skills/SKILL.md
Normal file
130
.claude/skills/update-skills/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: update-skills
|
||||
description: Check for and apply updates to installed skill branches from upstream.
|
||||
---
|
||||
|
||||
# About
|
||||
|
||||
Skills are distributed as git branches (`skill/*`). When you install a skill, you merge its branch into your repo. This skill checks upstream for newer commits on those skill branches and helps you update.
|
||||
|
||||
Run `/update-skills` in Claude Code.
|
||||
|
||||
## How it works
|
||||
|
||||
**Preflight**: checks for clean working tree and upstream remote.
|
||||
|
||||
**Detection**: fetches upstream, lists all `upstream/skill/*` branches, determines which ones you've previously merged (via merge-base), and checks if any have new commits.
|
||||
|
||||
**Selection**: presents a list of skills with available updates. You pick which to update.
|
||||
|
||||
**Update**: merges each selected skill branch, resolves conflicts if any, then validates with build + test.
|
||||
|
||||
---
|
||||
|
||||
# Goal
|
||||
Help users update their installed skill branches from upstream without losing local customizations.
|
||||
|
||||
# Operating principles
|
||||
- Never proceed with a dirty working tree.
|
||||
- Only offer updates for skills the user has already merged (installed).
|
||||
- Use git-native operations. Do not manually rewrite files except conflict markers.
|
||||
- Keep token usage low: rely on `git` commands, only open files with actual conflicts.
|
||||
|
||||
# Step 0: Preflight
|
||||
|
||||
Run:
|
||||
- `git status --porcelain`
|
||||
|
||||
If output is non-empty:
|
||||
- Tell the user to commit or stash first, then stop.
|
||||
|
||||
Check remotes:
|
||||
- `git remote -v`
|
||||
|
||||
If `upstream` is missing:
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
|
||||
- `git remote add upstream <url>`
|
||||
|
||||
Fetch:
|
||||
- `git fetch upstream --prune`
|
||||
|
||||
# Step 1: Detect installed skills with available updates
|
||||
|
||||
List all upstream skill branches:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
|
||||
For each `upstream/skill/<name>`:
|
||||
1. Check if the user has merged this skill branch before:
|
||||
- `git merge-base --is-ancestor upstream/skill/<name>~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/<name>" HEAD` to see if there's a merge commit referencing this branch.
|
||||
- Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/<name>)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged.
|
||||
- Simplest reliable check: compare `git merge-base HEAD upstream/skill/<name>` with `git merge-base HEAD upstream/main`. If the skill merge-base is strictly ahead of (or different from) the main merge-base, the user has merged this skill.
|
||||
2. Check if there are new commits on the skill branch not yet in HEAD:
|
||||
- `git log --oneline HEAD..upstream/skill/<name>`
|
||||
- If this produces output, there are updates available.
|
||||
|
||||
Build three lists:
|
||||
- **Updates available**: skills that are merged AND have new commits
|
||||
- **Up to date**: skills that are merged and have no new commits
|
||||
- **Not installed**: skills that have never been merged
|
||||
|
||||
# Step 2: Present results
|
||||
|
||||
If no skills have updates available:
|
||||
- Tell the user all installed skills are up to date. List them.
|
||||
- If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed").
|
||||
- Stop here.
|
||||
|
||||
If updates are available:
|
||||
- Show the list of skills with updates, including the number of new commits for each:
|
||||
```
|
||||
skill/<name>: 3 new commits
|
||||
skill/<other>: 1 new commit
|
||||
```
|
||||
- Also show skills that are up to date (for context).
|
||||
- Use AskUserQuestion with `multiSelect: true` to let the user pick which skills to update.
|
||||
- One option per skill with updates, labeled with the skill name and commit count.
|
||||
- Add an option: "Skip — don't update any skills now"
|
||||
- If user selects Skip, stop here.
|
||||
|
||||
# Step 3: Apply updates
|
||||
|
||||
For each selected skill (process one at a time):
|
||||
|
||||
1. Tell the user which skill is being updated.
|
||||
2. Run: `git merge upstream/skill/<name> --no-edit`
|
||||
3. If the merge is clean, move to the next skill.
|
||||
4. If conflicts occur:
|
||||
- Run `git status` to identify conflicted files.
|
||||
- For each conflicted file:
|
||||
- Open the file.
|
||||
- Resolve only conflict markers.
|
||||
- Preserve intentional local customizations.
|
||||
- `git add <file>`
|
||||
- Complete the merge: `git commit --no-edit`
|
||||
|
||||
If a merge fails badly (e.g., cannot resolve conflicts):
|
||||
- `git merge --abort`
|
||||
- Tell the user this skill could not be auto-updated and they should resolve it manually.
|
||||
- Continue with the remaining skills.
|
||||
|
||||
# Step 4: Validation
|
||||
|
||||
After all selected skills are merged:
|
||||
- `npm run build`
|
||||
- `npm test` (do not fail the flow if tests are not configured)
|
||||
|
||||
If build fails:
|
||||
- Show the error.
|
||||
- Only fix issues clearly caused by the merge (missing imports, type mismatches).
|
||||
- Do not refactor unrelated code.
|
||||
- If unclear, ask the user.
|
||||
|
||||
# Step 5: Summary
|
||||
|
||||
Show:
|
||||
- Skills updated (list)
|
||||
- Skills skipped or failed (if any)
|
||||
- New HEAD: `git rev-parse --short HEAD`
|
||||
- Any conflicts that were resolved (list files)
|
||||
|
||||
If the service is running, remind the user to restart it to pick up changes.
|
||||
@@ -1,171 +0,0 @@
|
||||
---
|
||||
name: update
|
||||
description: "Update NanoClaw from upstream. Fetches latest changes, merges with your customizations and skills, runs migrations. Triggers on \"update\", \"pull upstream\", \"sync with upstream\", \"get latest changes\"."
|
||||
---
|
||||
|
||||
# Update NanoClaw
|
||||
|
||||
Pull upstream changes and merge them with the user's installation, preserving skills and customizations. Scripts live in `.claude/skills/update/scripts/`.
|
||||
|
||||
**Principle:** Handle everything automatically. Only pause for user confirmation before applying changes, or when merge conflicts need human judgment.
|
||||
|
||||
**UX Note:** Use `AskUserQuestion` for all user-facing questions.
|
||||
|
||||
## 1. Pre-flight
|
||||
|
||||
Check that the skills system is initialized:
|
||||
|
||||
```bash
|
||||
test -d .nanoclaw && echo "INITIALIZED" || echo "NOT_INITIALIZED"
|
||||
```
|
||||
|
||||
**If NOT_INITIALIZED:** Run `initSkillsSystem()` first:
|
||||
|
||||
```bash
|
||||
npx tsx -e "import { initNanoclawDir } from './skills-engine/init.js'; initNanoclawDir();"
|
||||
```
|
||||
|
||||
Check for uncommitted git changes:
|
||||
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
**If there are uncommitted changes:** Warn the user: "You have uncommitted changes. It's recommended to commit or stash them before updating. Continue anyway?" Use `AskUserQuestion` with options: "Continue anyway", "Abort (I'll commit first)". If they abort, stop here.
|
||||
|
||||
## 2. Fetch upstream
|
||||
|
||||
Run the fetch script:
|
||||
|
||||
```bash
|
||||
./.claude/skills/update/scripts/fetch-upstream.sh
|
||||
```
|
||||
|
||||
Parse the structured status block between `<<< STATUS` and `STATUS >>>` markers. Extract:
|
||||
- `TEMP_DIR` — path to extracted upstream files
|
||||
- `REMOTE` — which git remote was used
|
||||
- `CURRENT_VERSION` — version from local `package.json`
|
||||
- `NEW_VERSION` — version from upstream `package.json`
|
||||
- `STATUS` — "success" or "error"
|
||||
|
||||
**If STATUS=error:** Show the error output and stop.
|
||||
|
||||
**If CURRENT_VERSION equals NEW_VERSION:** Tell the user they're already up to date. Ask if they want to force the update anyway (there may be non-version-bumped changes). If no, clean up the temp dir and stop.
|
||||
|
||||
## 3. Preview
|
||||
|
||||
Run the preview to show what will change:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/update-core.ts --json --preview-only <TEMP_DIR>
|
||||
```
|
||||
|
||||
This outputs JSON with: `currentVersion`, `newVersion`, `filesChanged`, `filesDeleted`, `conflictRisk`, `customPatchesAtRisk`.
|
||||
|
||||
Present to the user:
|
||||
- "Updating from **{currentVersion}** to **{newVersion}**"
|
||||
- "{N} files will be changed" — list them if <= 20, otherwise summarize
|
||||
- If `conflictRisk` is non-empty: "These files have skill modifications and may conflict: {list}"
|
||||
- If `customPatchesAtRisk` is non-empty: "These custom patches may need re-application: {list}"
|
||||
- If `filesDeleted` is non-empty: "{N} files will be removed"
|
||||
|
||||
## 4. Confirm
|
||||
|
||||
Use `AskUserQuestion`: "Apply this update?" with options:
|
||||
- "Yes, apply update"
|
||||
- "No, cancel"
|
||||
|
||||
If cancelled, clean up the temp dir (`rm -rf <TEMP_DIR>`) and stop.
|
||||
|
||||
## 5. Apply
|
||||
|
||||
Run the update:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/update-core.ts --json <TEMP_DIR>
|
||||
```
|
||||
|
||||
Parse the JSON output. The result has: `success`, `previousVersion`, `newVersion`, `mergeConflicts`, `backupPending`, `customPatchFailures`, `skillReapplyResults`, `error`.
|
||||
|
||||
**If success=true with no issues:** Continue to step 7.
|
||||
|
||||
**If customPatchFailures exist:** Warn the user which custom patches failed to re-apply. These may need manual attention after the update.
|
||||
|
||||
**If skillReapplyResults has false entries:** Warn the user which skill tests failed after re-application.
|
||||
|
||||
## 6. Handle conflicts
|
||||
|
||||
**If backupPending=true:** There are unresolved merge conflicts.
|
||||
|
||||
For each file in `mergeConflicts`:
|
||||
1. Read the file — it contains conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)
|
||||
2. Check if there's an intent file for this path in any applied skill (e.g., `.claude/skills/<skill>/modify/<path>.intent.md`)
|
||||
3. Use the intent file and your understanding of the codebase to resolve the conflict
|
||||
4. Write the resolved file
|
||||
|
||||
After resolving all conflicts:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/post-update.ts
|
||||
```
|
||||
|
||||
This clears the backup, confirming the resolution.
|
||||
|
||||
**If you cannot confidently resolve a conflict:** Show the user the conflicting sections and ask them to choose or provide guidance.
|
||||
|
||||
## 7. Run migrations
|
||||
|
||||
Run migrations between the old and new versions:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/run-migrations.ts <CURRENT_VERSION> <NEW_VERSION> <TEMP_DIR>
|
||||
```
|
||||
|
||||
Parse the JSON output. It contains: `migrationsRun` (count), `results` (array of `{version, success, error?}`).
|
||||
|
||||
**If any migration fails:** Show the error to the user. The update itself is already applied — the migration failure needs manual attention.
|
||||
|
||||
**If no migrations found:** This is normal (most updates won't have migrations). Continue silently.
|
||||
|
||||
## 8. Verify
|
||||
|
||||
Run build and tests:
|
||||
|
||||
```bash
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
**If build fails:** Show the error. Common causes:
|
||||
- Type errors from merged files — read the error, fix the file, retry
|
||||
- Missing dependencies — run `npm install` first, retry
|
||||
|
||||
**If tests fail:** Show which tests failed. Try to diagnose and fix. If you can't fix automatically, report to the user.
|
||||
|
||||
**If both pass:** Report success.
|
||||
|
||||
## 9. Cleanup
|
||||
|
||||
Remove the temp directory:
|
||||
|
||||
```bash
|
||||
rm -rf <TEMP_DIR>
|
||||
```
|
||||
|
||||
Report final status:
|
||||
- "Updated from **{previousVersion}** to **{newVersion}**"
|
||||
- Number of files changed
|
||||
- Any warnings (failed custom patches, failed skill tests, migration issues)
|
||||
- Build and test status
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No upstream remote:** The fetch script auto-adds `upstream` pointing to `https://github.com/qwibitai/nanoclaw.git`. If the user forked from a different URL, they should set the remote manually: `git remote add upstream <url>`.
|
||||
|
||||
**Merge conflicts in many files:** Consider whether the user has heavily customized core files. Suggest using the skills system for modifications instead of direct edits, as skills survive updates better.
|
||||
|
||||
**Build fails after update:** Check if `package.json` dependencies changed. Run `npm install` to pick up new dependencies.
|
||||
|
||||
**Rollback:** If something goes wrong after applying but before cleanup, the backup is still in `.nanoclaw/backup/`. Run:
|
||||
```bash
|
||||
npx tsx -e "import { restoreBackup, clearBackup } from './skills-engine/backup.js'; restoreBackup(); clearBackup();"
|
||||
```
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch upstream NanoClaw and extract to a temp directory.
|
||||
# Outputs a structured status block for machine parsing.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Determine the correct remote
|
||||
REMOTE=""
|
||||
if git remote get-url upstream &>/dev/null; then
|
||||
REMOTE="upstream"
|
||||
elif git remote get-url origin &>/dev/null; then
|
||||
ORIGIN_URL=$(git remote get-url origin)
|
||||
if echo "$ORIGIN_URL" | grep -q "qwibitai/nanoclaw"; then
|
||||
REMOTE="origin"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE" ]; then
|
||||
echo "No upstream remote found. Adding upstream → https://github.com/qwibitai/nanoclaw.git"
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
REMOTE="upstream"
|
||||
fi
|
||||
|
||||
echo "Fetching from $REMOTE..."
|
||||
if ! git fetch "$REMOTE" main 2>&1; then
|
||||
echo "<<< STATUS"
|
||||
echo "STATUS=error"
|
||||
echo "ERROR=Failed to fetch from $REMOTE"
|
||||
echo "STATUS >>>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current version from local package.json
|
||||
CURRENT_VERSION="unknown"
|
||||
if [ -f package.json ]; then
|
||||
CURRENT_VERSION=$(node -e "console.log(require('./package.json').version || 'unknown')")
|
||||
fi
|
||||
|
||||
# Create temp dir and extract only the paths the skills engine tracks.
|
||||
# Read BASE_INCLUDES from the single source of truth in skills-engine/constants.ts,
|
||||
# plus always include migrations/ for the migration runner.
|
||||
TEMP_DIR=$(mktemp -d /tmp/nanoclaw-update-XXXX)
|
||||
trap 'rm -rf "$TEMP_DIR"' ERR
|
||||
echo "Extracting $REMOTE/main to $TEMP_DIR..."
|
||||
|
||||
CANDIDATES=$(node -e "
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync('skills-engine/constants.ts', 'utf-8');
|
||||
const m = src.match(/BASE_INCLUDES\s*=\s*\[([^\]]+)\]/);
|
||||
if (!m) { console.error('Cannot parse BASE_INCLUDES'); process.exit(1); }
|
||||
const paths = m[1].match(/'([^']+)'/g).map(s => s.replace(/'/g, ''));
|
||||
paths.push('migrations/');
|
||||
console.log(paths.join(' '));
|
||||
")
|
||||
|
||||
# Filter to paths that actually exist in the upstream tree.
|
||||
# git archive errors if a path doesn't exist, so we check first.
|
||||
PATHS=""
|
||||
for candidate in $CANDIDATES; do
|
||||
if [ -n "$(git ls-tree --name-only "$REMOTE/main" "$candidate" 2>/dev/null)" ]; then
|
||||
PATHS="$PATHS $candidate"
|
||||
fi
|
||||
done
|
||||
|
||||
git archive "$REMOTE/main" -- $PATHS | tar -x -C "$TEMP_DIR"
|
||||
|
||||
# Get new version from extracted package.json
|
||||
NEW_VERSION="unknown"
|
||||
if [ -f "$TEMP_DIR/package.json" ]; then
|
||||
NEW_VERSION=$(node -e "console.log(require('$TEMP_DIR/package.json').version || 'unknown')")
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "<<< STATUS"
|
||||
echo "TEMP_DIR=$TEMP_DIR"
|
||||
echo "REMOTE=$REMOTE"
|
||||
echo "CURRENT_VERSION=$CURRENT_VERSION"
|
||||
echo "NEW_VERSION=$NEW_VERSION"
|
||||
echo "STATUS=success"
|
||||
echo "STATUS >>>"
|
||||
152
.claude/skills/use-local-whisper/SKILL.md
Normal file
152
.claude/skills/use-local-whisper/SKILL.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: use-local-whisper
|
||||
description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first.
|
||||
---
|
||||
|
||||
# Use Local Whisper
|
||||
|
||||
Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost.
|
||||
|
||||
**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them.
|
||||
|
||||
**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `voice-transcription` skill must be applied first (WhatsApp channel)
|
||||
- macOS with Apple Silicon (M1+) recommended
|
||||
- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary)
|
||||
- `ffmpeg` installed: `brew install ffmpeg`
|
||||
- A GGML model file downloaded to `data/models/`
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/transcription.ts` already uses `whisper-cli`:
|
||||
|
||||
```bash
|
||||
grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
### Check dependencies are installed
|
||||
|
||||
```bash
|
||||
whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING"
|
||||
ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING"
|
||||
```
|
||||
|
||||
If missing, install via Homebrew:
|
||||
```bash
|
||||
brew install whisper-cpp ffmpeg
|
||||
```
|
||||
|
||||
### Check for model file
|
||||
|
||||
```bash
|
||||
ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL"
|
||||
```
|
||||
|
||||
If no model exists, download the base model (148MB, good balance of speed and accuracy):
|
||||
```bash
|
||||
mkdir -p data/models
|
||||
curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"
|
||||
```
|
||||
|
||||
For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/local-whisper
|
||||
git merge whatsapp/skill/local-whisper || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Ensure launchd PATH includes Homebrew
|
||||
|
||||
The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH.
|
||||
|
||||
Check the current PATH:
|
||||
```bash
|
||||
grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
```
|
||||
|
||||
If `/opt/homebrew/bin` is missing, add it to the `<string>` value inside the `PATH` key in the plist. Then reload:
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
```
|
||||
|
||||
### Build and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
Send a voice note in any registered group. The agent should receive it as `[Voice: <transcript>]`.
|
||||
|
||||
### Check logs
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper"
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `Transcribed voice message` — successful transcription
|
||||
- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (optional, set in `.env`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary |
|
||||
| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually:
|
||||
```bash
|
||||
ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y
|
||||
whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt
|
||||
```
|
||||
|
||||
**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3.
|
||||
|
||||
**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing.
|
||||
|
||||
**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`.
|
||||
167
.claude/skills/use-native-credential-proxy/SKILL.md
Normal file
167
.claude/skills/use-native-credential-proxy/SKILL.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: use-native-credential-proxy
|
||||
description: Replace OneCLI gateway with the built-in credential proxy. For users who want simple .env-based credential management without installing OneCLI. Reads API key or OAuth token from .env and injects into container API requests.
|
||||
---
|
||||
|
||||
# Use Native Credential Proxy
|
||||
|
||||
This skill replaces the OneCLI gateway with NanoClaw's built-in credential proxy. Containers get credentials injected via a local HTTP proxy that reads from `.env` — no external services needed.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/credential-proxy.ts` is imported in `src/index.ts`:
|
||||
|
||||
```bash
|
||||
grep "credential-proxy" src/index.ts
|
||||
```
|
||||
|
||||
If it shows an import for `startCredentialProxy`, the native proxy is already active. Skip to Phase 3 (Setup).
|
||||
|
||||
### Check if OneCLI is active
|
||||
|
||||
```bash
|
||||
grep "@onecli-sh/sdk" package.json
|
||||
```
|
||||
|
||||
If `@onecli-sh/sdk` appears, OneCLI is the active credential provider. Proceed with Phase 2 to replace it.
|
||||
|
||||
If neither check matches, you may be on an older version. Run `/update-nanoclaw` first, then retry.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/native-credential-proxy
|
||||
git merge upstream/skill/native-credential-proxy || {
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/credential-proxy.ts` and `src/credential-proxy.test.ts` (the proxy implementation)
|
||||
- Restored credential proxy usage in `src/index.ts`, `src/container-runner.ts`, `src/container-runtime.ts`, `src/config.ts`
|
||||
- Removed `@onecli-sh/sdk` dependency
|
||||
- Restored `CREDENTIAL_PROXY_PORT` config (default 3001)
|
||||
- Restored platform-aware proxy bind address detection
|
||||
- Reverted setup skill to `.env`-based credential instructions
|
||||
|
||||
If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Update main group CLAUDE.md
|
||||
|
||||
Replace the OneCLI auth reference with the native proxy:
|
||||
|
||||
In `groups/main/CLAUDE.md`, replace:
|
||||
> OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
|
||||
|
||||
with:
|
||||
> The native credential proxy manages credentials (including Anthropic auth) via `.env` — see `src/credential-proxy.ts`.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx vitest run src/credential-proxy.test.ts src/container-runner.test.ts
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup Credentials
|
||||
|
||||
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
|
||||
|
||||
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
|
||||
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
|
||||
|
||||
### Subscription path
|
||||
|
||||
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
|
||||
|
||||
Once they have the token, add it to `.env`:
|
||||
|
||||
```bash
|
||||
# Add to .env (create file if needed)
|
||||
echo 'CLAUDE_CODE_OAUTH_TOKEN=<token>' >> .env
|
||||
```
|
||||
|
||||
Note: `ANTHROPIC_AUTH_TOKEN` is also supported as a fallback.
|
||||
|
||||
### API key path
|
||||
|
||||
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
|
||||
|
||||
Add it to `.env`:
|
||||
|
||||
```bash
|
||||
echo 'ANTHROPIC_API_KEY=<key>' >> .env
|
||||
```
|
||||
|
||||
### After either path
|
||||
|
||||
**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): write it to `.env` on their behalf using the appropriate variable name.
|
||||
|
||||
**Optional:** If the user needs a custom API endpoint, they can add `ANTHROPIC_BASE_URL=<url>` to `.env` (defaults to `https://api.anthropic.com`).
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
1. Rebuild and restart:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then restart the service:
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
2. Check logs for successful proxy startup:
|
||||
|
||||
```bash
|
||||
tail -20 logs/nanoclaw.log | grep "Credential proxy"
|
||||
```
|
||||
|
||||
Expected: `Credential proxy started` with port and auth mode.
|
||||
|
||||
3. Send a test message in the registered chat to verify the agent responds.
|
||||
|
||||
4. Note: after applying this skill, the OneCLI credential steps in `/setup` no longer apply. `.env` is now the credential source.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Credential proxy upstream error" in logs:** Check that `.env` has a valid `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`. Verify the API is reachable: `curl -s https://api.anthropic.com/v1/messages -H "x-api-key: test" | head`.
|
||||
|
||||
**Port 3001 already in use:** Set `CREDENTIAL_PROXY_PORT=<other port>` in `.env` or as an environment variable.
|
||||
|
||||
**Container can't reach proxy (Linux):** The proxy binds to the `docker0` bridge IP by default. If that interface doesn't exist (e.g. rootless Docker), set `CREDENTIAL_PROXY_HOST=0.0.0.0` as an environment variable.
|
||||
|
||||
**OAuth token expired (401 errors):** Re-run `claude setup-token` in a terminal and update the token in `.env`.
|
||||
|
||||
## Removal
|
||||
|
||||
To revert to OneCLI gateway:
|
||||
|
||||
1. Find the merge commit: `git log --oneline --merges -5`
|
||||
2. Revert it: `git revert <merge-commit> -m 1` (undoes the skill branch merge, keeps your other changes)
|
||||
3. `npm install` (re-adds `@onecli-sh/sdk`)
|
||||
4. `npm run build`
|
||||
5. Follow `/setup` step 4 to configure OneCLI credentials
|
||||
6. Remove `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from `.env`
|
||||
@@ -1 +1 @@
|
||||
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,14 +1,18 @@
|
||||
<!-- contributing-guide: v1 -->
|
||||
## Type of Change
|
||||
|
||||
- [ ] **Skill** - adds a new skill in `.claude/skills/`
|
||||
- [ ] **Feature skill** - adds a channel or integration (source code changes + SKILL.md)
|
||||
- [ ] **Utility skill** - adds a standalone tool (code files in `.claude/skills/<name>/`, no source changes)
|
||||
- [ ] **Operational/container skill** - adds a workflow or agent skill (SKILL.md only, no source changes)
|
||||
- [ ] **Fix** - bug fix or security fix to source code
|
||||
- [ ] **Simplification** - reduces or simplifies source code
|
||||
- [ ] **Documentation** - docs, README, or CONTRIBUTING changes only
|
||||
|
||||
## Description
|
||||
|
||||
|
||||
## For Skills
|
||||
|
||||
- [ ] I have not made any changes to source code
|
||||
- [ ] My skill contains instructions for Claude to follow (not pre-built code)
|
||||
- [ ] SKILL.md contains instructions, not inline code (code goes in separate files)
|
||||
- [ ] SKILL.md is under 500 lines
|
||||
- [ ] I tested this skill on a fresh clone
|
||||
|
||||
1
.github/workflows/bump-version.yml
vendored
1
.github/workflows/bump-version.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
35
.github/workflows/label-pr.yml
vendored
Normal file
35
.github/workflows/label-pr.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Label PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || '';
|
||||
const labels = [];
|
||||
|
||||
if (body.includes('[x] **Feature skill**')) { labels.push('PR: Skill'); labels.push('PR: Feature'); }
|
||||
else if (body.includes('[x] **Utility skill**')) labels.push('PR: Skill');
|
||||
else if (body.includes('[x] **Operational/container skill**')) labels.push('PR: Skill');
|
||||
else if (body.includes('[x] **Fix**')) labels.push('PR: Fix');
|
||||
else if (body.includes('[x] **Simplification**')) labels.push('PR: Refactor');
|
||||
else if (body.includes('[x] **Documentation**')) labels.push('PR: Docs');
|
||||
|
||||
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels,
|
||||
});
|
||||
}
|
||||
102
.github/workflows/skill-drift.yml
vendored
102
.github/workflows/skill-drift.yml
vendored
@@ -1,102 +0,0 @@
|
||||
name: Skill Drift Detection
|
||||
|
||||
# Runs after every push to main that touches source files.
|
||||
# Validates every skill can still be cleanly applied, type-checked, and tested.
|
||||
# If a skill drifts, attempts auto-fix via three-way merge of modify/ files,
|
||||
# then opens a PR with the result (auto-fixed or with conflict markers).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'container/**'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Step 1: Check all skills against current main ─────────────────────
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
drifted: ${{ steps.check.outputs.drifted }}
|
||||
drifted_skills: ${{ steps.check.outputs.drifted_skills }}
|
||||
results: ${{ steps.check.outputs.results }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Validate all skills against main
|
||||
id: check
|
||||
run: npx tsx scripts/validate-all-skills.ts
|
||||
continue-on-error: true
|
||||
|
||||
# ── Step 2: Auto-fix and create PR ────────────────────────────────────
|
||||
fix-drift:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.drifted == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Attempt auto-fix via three-way merge
|
||||
id: fix
|
||||
run: |
|
||||
SKILLS=$(echo '${{ needs.validate.outputs.drifted_skills }}' | jq -r '.[]')
|
||||
npx tsx scripts/fix-skill-drift.ts $SKILLS
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
branch: ci/fix-skill-drift
|
||||
delete-branch: true
|
||||
title: 'fix(skills): auto-update drifted skills'
|
||||
body: |
|
||||
## Skill Drift Detected
|
||||
|
||||
A push to `main` (${{ github.sha }}) changed source files that caused
|
||||
the following skills to fail validation:
|
||||
|
||||
**Drifted:** ${{ needs.validate.outputs.drifted_skills }}
|
||||
|
||||
### Auto-fix results
|
||||
|
||||
${{ steps.fix.outputs.summary }}
|
||||
|
||||
### What to do
|
||||
|
||||
1. Review the changes to `.claude/skills/*/modify/` files
|
||||
2. If there are conflict markers (`<<<<<<<`), resolve them
|
||||
3. CI will run typecheck + tests on this PR automatically
|
||||
4. Merge when green
|
||||
|
||||
---
|
||||
*Auto-generated by [skill-drift CI](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*
|
||||
labels: skill-drift,automated
|
||||
commit-message: 'fix(skills): auto-update drifted skill modify/ files'
|
||||
151
.github/workflows/skill-pr.yml
vendored
151
.github/workflows/skill-pr.yml
vendored
@@ -1,151 +0,0 @@
|
||||
name: Skill PR Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.claude/skills/**'
|
||||
- 'skills-engine/**'
|
||||
|
||||
jobs:
|
||||
# ── Job 1: Policy gate ────────────────────────────────────────────────
|
||||
# Block PRs that add NEW skill files while also modifying source code.
|
||||
# Skill PRs should contain instructions for Claude, not raw source edits.
|
||||
policy-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for mixed skill + source changes
|
||||
run: |
|
||||
ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD \
|
||||
| grep '^\\.claude/skills/' || true)
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD)
|
||||
SOURCE=$(echo "$CHANGED" \
|
||||
| grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true)
|
||||
|
||||
if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then
|
||||
echo "::error::PRs that add new skills should not modify source files."
|
||||
echo ""
|
||||
echo "New skill files:"
|
||||
echo "$ADDED_SKILLS"
|
||||
echo ""
|
||||
echo "Source files:"
|
||||
echo "$SOURCE"
|
||||
echo ""
|
||||
echo "Please split into separate PRs. See CONTRIBUTING.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow.
|
||||
|
||||
If you're fixing a bug or simplifying code, please submit that as a separate PR.
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.`
|
||||
})
|
||||
|
||||
# ── Job 2: Detect which skills changed ────────────────────────────────
|
||||
detect-changed:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skills: ${{ steps.detect.outputs.skills }}
|
||||
has_skills: ${{ steps.detect.outputs.has_skills }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed skills
|
||||
id: detect
|
||||
run: |
|
||||
CHANGED_SKILLS=$(git diff --name-only origin/main...HEAD \
|
||||
| grep '^\\.claude/skills/' \
|
||||
| sed 's|^\.claude/skills/||' \
|
||||
| cut -d/ -f1 \
|
||||
| sort -u \
|
||||
| jq -R . | jq -s .)
|
||||
echo "skills=$CHANGED_SKILLS" >> "$GITHUB_OUTPUT"
|
||||
if [ "$CHANGED_SKILLS" = "[]" ]; then
|
||||
echo "has_skills=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_skills=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Changed skills: $CHANGED_SKILLS"
|
||||
|
||||
# ── Job 3: Validate each changed skill in isolation ───────────────────
|
||||
validate-skills:
|
||||
needs: detect-changed
|
||||
if: needs.detect-changed.outputs.has_skills == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
skill: ${{ fromJson(needs.detect-changed.outputs.skills) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Initialize skills system
|
||||
run: >-
|
||||
npx tsx -e
|
||||
"import { initNanoclawDir } from './skills-engine/index'; initNanoclawDir();"
|
||||
|
||||
- name: Apply skill
|
||||
run: npx tsx scripts/apply-skill.ts ".claude/skills/${{ matrix.skill }}"
|
||||
|
||||
- name: Typecheck after apply
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run skill tests
|
||||
run: |
|
||||
TEST_CMD=$(npx tsx -e "
|
||||
import { parse } from 'yaml';
|
||||
import fs from 'fs';
|
||||
const m = parse(fs.readFileSync('.claude/skills/${{ matrix.skill }}/manifest.yaml', 'utf-8'));
|
||||
if (m.test) console.log(m.test);
|
||||
")
|
||||
if [ -n "$TEST_CMD" ]; then
|
||||
echo "Running: $TEST_CMD"
|
||||
eval "$TEST_CMD"
|
||||
else
|
||||
echo "No test command defined, skipping"
|
||||
fi
|
||||
|
||||
# ── Summary gate for branch protection ────────────────────────────────
|
||||
skill-validation-summary:
|
||||
needs:
|
||||
- policy-check
|
||||
- detect-changed
|
||||
- validate-skills
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check results
|
||||
run: |
|
||||
echo "policy-check: ${{ needs.policy-check.result }}"
|
||||
echo "validate-skills: ${{ needs.validate-skills.result }}"
|
||||
|
||||
if [ "${{ needs.policy-check.result }}" = "failure" ]; then
|
||||
echo "::error::Policy check failed"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ needs.validate-skills.result }}" = "failure" ]; then
|
||||
echo "::error::Skill validation failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All skill checks passed"
|
||||
1
.github/workflows/update-tokens.yml
vendored
1
.github/workflows/update-tokens.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-tokens:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ groups/global/*
|
||||
*.keys.json
|
||||
.env
|
||||
|
||||
# Temp files
|
||||
.tmp-*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
|
||||
141
CHANGELOG.md
141
CHANGELOG.md
@@ -1,3 +1,144 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
|
||||
## [1.2.35] - 2026-03-26
|
||||
|
||||
- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Existing `.env` credentials must be migrated to the vault. Run `/init-onecli` to install OneCLI and migrate credentials.
|
||||
|
||||
## [1.2.21] - 2026-03-22
|
||||
|
||||
- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again)
|
||||
|
||||
## [1.2.20] - 2026-03-21
|
||||
|
||||
- Added ESLint configuration with error-handling rules
|
||||
|
||||
## [1.2.19] - 2026-03-19
|
||||
|
||||
- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag)
|
||||
|
||||
## [1.2.18] - 2026-03-19
|
||||
|
||||
- User prompt content no longer logged on container errors — only input metadata
|
||||
- Added Japanese README translation
|
||||
|
||||
## [1.2.17] - 2026-03-18
|
||||
|
||||
- Added `/capabilities` and `/status` container-agent skills
|
||||
|
||||
## [1.2.16] - 2026-03-18
|
||||
|
||||
- Tasks snapshot now refreshes immediately after IPC task mutations
|
||||
|
||||
## [1.2.15] - 2026-03-16
|
||||
|
||||
- Fixed remote-control prompt auto-accept to prevent immediate exit
|
||||
- Added `KillMode=process` so remote-control survives service restarts
|
||||
|
||||
## [1.2.14] - 2026-03-14
|
||||
|
||||
- Added `/remote-control` command for host-level Claude Code access from within containers
|
||||
|
||||
## [1.2.13] - 2026-03-14
|
||||
|
||||
**Breaking:** Skills are now git branches, channels are separate fork repos.
|
||||
|
||||
- Skills live as `skill/*` git branches merged via `git merge`
|
||||
- Added Docker Sandboxes support
|
||||
- Fixed setup registration to use correct CLI commands
|
||||
|
||||
## [1.2.12] - 2026-03-08
|
||||
|
||||
- Added `/compact` skill for manual context compaction
|
||||
- Enhanced container environment isolation via credential proxy
|
||||
|
||||
## [1.2.11] - 2026-03-08
|
||||
|
||||
- Added PDF reader, image vision, and WhatsApp reactions skills
|
||||
- Fixed task container to close promptly when agent uses IPC-only messaging
|
||||
|
||||
## [1.2.10] - 2026-03-06
|
||||
|
||||
- Added `LIMIT` to unbounded message history queries for better performance
|
||||
|
||||
## [1.2.9] - 2026-03-06
|
||||
|
||||
- Agent prompts now include timezone context for accurate time references
|
||||
|
||||
## [1.2.8] - 2026-03-06
|
||||
|
||||
- Fixed misleading `send_message` tool description for scheduled tasks
|
||||
|
||||
## [1.2.7] - 2026-03-06
|
||||
|
||||
- Added `/add-ollama` skill for local model inference
|
||||
- Added `update_task` tool and return task ID from `schedule_task`
|
||||
|
||||
## [1.2.6] - 2026-03-04
|
||||
|
||||
- Updated `claude-agent-sdk` to 0.2.68
|
||||
|
||||
## [1.2.5] - 2026-03-04
|
||||
|
||||
- CI formatting fix
|
||||
|
||||
## [1.2.4] - 2026-03-04
|
||||
|
||||
- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback
|
||||
|
||||
## [1.2.3] - 2026-03-04
|
||||
|
||||
- Added sender allowlist for per-chat access control
|
||||
|
||||
## [1.2.2] - 2026-03-04
|
||||
|
||||
- Added `/use-local-whisper` skill for local voice transcription
|
||||
- Atomic task claims prevent scheduled tasks from executing twice
|
||||
|
||||
## [1.2.1] - 2026-03-02
|
||||
|
||||
- Version bump (no functional changes)
|
||||
|
||||
## [1.2.0] - 2026-03-02
|
||||
|
||||
**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add.
|
||||
|
||||
- Channel registry: channels self-register at startup via `registerChannel()` factory pattern
|
||||
- `isMain` flag replaces folder-name-based main group detection
|
||||
- `ENABLED_CHANNELS` removed — channels detected by credential presence
|
||||
- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval
|
||||
|
||||
## [1.1.6] - 2026-03-01
|
||||
|
||||
- Added CJK font support for Chromium screenshots
|
||||
|
||||
## [1.1.5] - 2026-03-01
|
||||
|
||||
- Fixed wrapped WhatsApp message normalization
|
||||
|
||||
## [1.1.4] - 2026-03-01
|
||||
|
||||
- Added third-party model support
|
||||
- Added `/update-nanoclaw` skill for syncing with upstream
|
||||
|
||||
## [1.1.3] - 2026-02-25
|
||||
|
||||
- Added `/add-slack` skill
|
||||
- Restructured Gmail skill for new architecture
|
||||
|
||||
## [1.1.2] - 2026-02-24
|
||||
|
||||
- Improved error handling for WhatsApp Web version fetch
|
||||
|
||||
## [1.1.1] - 2026-02-24
|
||||
|
||||
- Added Qodo skills and codebase intelligence
|
||||
- Fixed WhatsApp 405 connection failures
|
||||
|
||||
## [1.1.0] - 2026-02-23
|
||||
|
||||
- Added `/update` skill to pull upstream changes from within Claude Code
|
||||
- Enhanced container environment isolation via credential proxy
|
||||
|
||||
28
CLAUDE.md
28
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 |
|
||||
@@ -19,19 +19,35 @@ Single Node.js process that connects to WhatsApp, routes messages to Claude Agen
|
||||
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
||||
| `src/db.ts` | SQLite operations |
|
||||
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
||||
| `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
|
||||
| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
|
||||
|
||||
## Secrets / Credentials / Proxy (OneCLI)
|
||||
|
||||
API keys, secret keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway — which handles secret injection into containers at request time, so no keys or tokens are ever passed to containers directly. Run `onecli --help`.
|
||||
|
||||
## Skills
|
||||
|
||||
Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
|
||||
|
||||
- **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`)
|
||||
- **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`)
|
||||
- **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`)
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`)
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
| `/setup` | First-time installation, authentication, service configuration |
|
||||
| `/customize` | Adding channels, integrations, changing behavior |
|
||||
| `/debug` | Container issues, logs, troubleshooting |
|
||||
| `/update` | Pull upstream NanoClaw changes, merge with customizations, run migrations |
|
||||
| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install |
|
||||
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials to it |
|
||||
| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
|
||||
| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |
|
||||
|
||||
## Contributing
|
||||
|
||||
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format).
|
||||
|
||||
## Development
|
||||
|
||||
Run commands directly—don't tell the user to run them.
|
||||
@@ -55,6 +71,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`.
|
||||
|
||||
140
CONTRIBUTING.md
140
CONTRIBUTING.md
@@ -1,5 +1,18 @@
|
||||
# Contributing
|
||||
|
||||
## Before You Start
|
||||
|
||||
1. **Check for existing work.** Search open PRs and issues before starting:
|
||||
```bash
|
||||
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
```
|
||||
If a related PR or issue exists, build on it rather than duplicating effort.
|
||||
|
||||
2. **Check alignment.** Read the [Philosophy section in README.md](README.md#philosophy). Source code changes should only be things 90%+ of users need. Skills can be more niche, but should still be useful beyond a single person's setup.
|
||||
|
||||
3. **One thing per PR.** Each PR should do one thing — one bug fix, one skill, one simplification. Don't mix unrelated changes in a single PR.
|
||||
|
||||
## Source Code Changes
|
||||
|
||||
**Accepted:** Bug fixes, security fixes, simplifications, reducing code.
|
||||
@@ -8,16 +21,127 @@
|
||||
|
||||
## Skills
|
||||
|
||||
A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation.
|
||||
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
|
||||
|
||||
A PR that contributes a skill should not modify any source files.
|
||||
|
||||
Your skill should contain the **instructions** Claude follows to add the feature—not pre-built code. See `/add-telegram` for a good example.
|
||||
|
||||
### Why?
|
||||
### Why skills?
|
||||
|
||||
Every user should have clean and minimal code that does exactly what they need. Skills let users selectively add features to their fork without inheriting code for features they don't want.
|
||||
|
||||
### Testing
|
||||
### Skill types
|
||||
|
||||
Test your skill by running it on a fresh clone before submitting.
|
||||
#### 1. Feature skills (branch-based)
|
||||
|
||||
Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setup instructions; the actual code lives on a `skill/*` branch.
|
||||
|
||||
**Location:** `.claude/skills/` on `main` (instructions only), code on `skill/*` branch
|
||||
|
||||
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`
|
||||
|
||||
**How they work:**
|
||||
1. User runs `/add-telegram`
|
||||
2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch
|
||||
3. Claude walks through interactive setup (env vars, bot creation, etc.)
|
||||
|
||||
**Contributing a feature skill:**
|
||||
1. Fork `qwibitai/nanoclaw` and branch from `main`
|
||||
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
|
||||
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
|
||||
4. Open a PR. We'll create the `skill/<name>` branch from your work
|
||||
|
||||
See `/add-telegram` for a good example. See [docs/skills-as-branches.md](docs/skills-as-branches.md) for the full system design.
|
||||
|
||||
#### 2. Utility skills (with code files)
|
||||
|
||||
Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells Claude how to install the tool; the code lives in the skill directory itself (e.g. in a `scripts/` subfolder).
|
||||
|
||||
**Location:** `.claude/skills/<name>/` with supporting files
|
||||
|
||||
**Examples:** `/claw` (Python CLI in `scripts/claw`)
|
||||
|
||||
**Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation.
|
||||
|
||||
**Guidelines:**
|
||||
- Put code in separate files, not inline in the SKILL.md
|
||||
- Use `${CLAUDE_SKILL_DIR}` to reference files in the skill directory
|
||||
- SKILL.md contains installation instructions, usage docs, and troubleshooting
|
||||
|
||||
#### 3. Operational skills (instruction-only)
|
||||
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
|
||||
|
||||
**Location:** `.claude/skills/` on `main`
|
||||
|
||||
**Examples:** `/setup`, `/debug`, `/customize`, `/update-nanoclaw`, `/update-skills`
|
||||
|
||||
**Guidelines:**
|
||||
- Pure instructions — no code files, no branch merges
|
||||
- Use `AskUserQuestion` for interactive prompts
|
||||
- These stay on `main` and are always available to every user
|
||||
|
||||
#### 4. Container skills (agent runtime)
|
||||
|
||||
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
|
||||
|
||||
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
|
||||
|
||||
**Guidelines:**
|
||||
- Follow the same SKILL.md + frontmatter format
|
||||
- Use `allowed-tools` frontmatter to scope tool permissions
|
||||
- Keep them focused — the agent's context window is shared across all container skills
|
||||
|
||||
### SKILL.md format
|
||||
|
||||
All skills use the [Claude Code skills standard](https://code.claude.com/docs/en/skills):
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: What this skill does and when to use it.
|
||||
---
|
||||
|
||||
Instructions here...
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Keep SKILL.md **under 500 lines** — move detail to separate reference files
|
||||
- `name`: lowercase, alphanumeric + hyphens, max 64 chars
|
||||
- `description`: required — Claude uses this to decide when to invoke the skill
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
### Before opening
|
||||
|
||||
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
||||
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
|
||||
| Checkbox | Label |
|
||||
|----------|-------|
|
||||
| Feature skill | `PR: Skill` + `PR: Feature` |
|
||||
| Utility skill | `PR: Skill` |
|
||||
| Operational/container skill | `PR: Skill` |
|
||||
| Fix | `PR: Fix` |
|
||||
| Simplification | `PR: Refactor` |
|
||||
| Documentation | `PR: Docs` |
|
||||
|
||||
### PR description
|
||||
|
||||
Keep it concise. Remove any template sections that don't apply. The description should cover:
|
||||
|
||||
- **What** — what the PR adds or changes
|
||||
- **Why** — the motivation
|
||||
- **How it works** — brief explanation of the approach
|
||||
- **How it was tested** — what you did to verify it works
|
||||
- **Usage** — how the user invokes it (for skills)
|
||||
|
||||
Don't pad the description. A few clear sentences are better than lengthy paragraphs.
|
||||
|
||||
@@ -7,3 +7,12 @@ Thanks to everyone who has contributed to NanoClaw!
|
||||
- [pottertech](https://github.com/pottertech) — Skip Potter
|
||||
- [rgarcia](https://github.com/rgarcia) — Rafael
|
||||
- [AmaxGuan](https://github.com/AmaxGuan) — Lingfeng Guan
|
||||
- [happydog-intj](https://github.com/happydog-intj) — happy dog
|
||||
- [bindoon](https://github.com/bindoon) — 潕量
|
||||
- [taslim](https://github.com/taslim) — Taslim
|
||||
- [baijunjie](https://github.com/baijunjie) — BaiJunjie
|
||||
- [Michaelliv](https://github.com/Michaelliv) — Michael
|
||||
- [kk17](https://github.com/kk17) — Kyle Zhike Chen
|
||||
- [flobo3](https://github.com/flobo3) — Flo
|
||||
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
|
||||
- [scottgl9](https://github.com/scottgl9) — Scott Glover
|
||||
|
||||
74
README.md
74
README.md
@@ -8,14 +8,14 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">docs</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
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.
|
||||
---
|
||||
|
||||
## Why I Built NanoClaw
|
||||
|
||||
@@ -26,13 +26,25 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/NanoClaw.git
|
||||
cd NanoClaw
|
||||
gh repo fork qwibitai/nanoclaw --clone
|
||||
cd nanoclaw
|
||||
claude
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Without GitHub CLI</summary>
|
||||
|
||||
1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button)
|
||||
2. `git clone https://github.com/<your-username>/nanoclaw.git`
|
||||
3. `cd nanoclaw`
|
||||
4. `claude`
|
||||
|
||||
</details>
|
||||
|
||||
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. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code).
|
||||
|
||||
## 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,13 +66,14 @@ 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
|
||||
- **Web access** - Search and fetch content from the Web
|
||||
- **Container isolation** - Agents are sandboxed in Apple Container (macOS) or Docker (macOS/Linux)
|
||||
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks. NanoClaw is the first personal AI assistant to support agent swarms.
|
||||
- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
|
||||
- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
|
||||
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
|
||||
|
||||
## Usage
|
||||
@@ -97,7 +110,7 @@ The codebase is small enough that Claude can safely modify it.
|
||||
|
||||
**Don't add features. Add skills.**
|
||||
|
||||
If you want to add Telegram support, don't create a PR that adds Telegram alongside WhatsApp. Instead, contribute a skill file (`.claude/skills/add-telegram/SKILL.md`) that teaches Claude Code how to transform a NanoClaw installation to use Telegram.
|
||||
If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork.
|
||||
|
||||
Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case.
|
||||
|
||||
@@ -106,14 +119,11 @@ 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
|
||||
|
||||
**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.
|
||||
- `/add-signal` - Add Signal as a channel
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux
|
||||
- macOS, Linux, or Windows (via WSL2)
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
|
||||
@@ -121,14 +131,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 the [documentation site](https://docs.nanoclaw.dev/concepts/architecture).
|
||||
|
||||
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
|
||||
@@ -141,20 +153,36 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime.
|
||||
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux?**
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`.
|
||||
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`.
|
||||
|
||||
**Is this secure?**
|
||||
|
||||
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model.
|
||||
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. Credentials never enter the container — outbound API requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects authentication at the proxy level and supports rate limits and access policies. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model.
|
||||
|
||||
**Why no configuration files?**
|
||||
|
||||
We don't want configuration sprawl. Every user should customize NanoClaw so that the code does exactly what they want, rather than configuring a generic system. If you prefer having config files, you can tell Claude to add them.
|
||||
|
||||
**Can I use third-party or open-source models?**
|
||||
|
||||
Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
This allows you to use:
|
||||
- Local models via [Ollama](https://ollama.ai) with an API proxy
|
||||
- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc.
|
||||
- Custom model deployments with Anthropic-compatible APIs
|
||||
|
||||
Note: The model must support the Anthropic API format for best compatibility.
|
||||
|
||||
**How do I debug issues?**
|
||||
|
||||
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw.
|
||||
@@ -175,6 +203,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, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
232
README_ja.md
Normal file
232
README_ja.md
Normal file
@@ -0,0 +1,232 @@
|
||||
<p align="center">
|
||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
エージェントを専用コンテナで安全に実行するAIアシスタント。軽量で、理解しやすく、あなたのニーズに完全にカスタマイズできるように設計されています。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
|
||||
<p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p>
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
|
||||
```
|
||||
|
||||
**Windows (WSL)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
|
||||
```
|
||||
|
||||
> 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。
|
||||
|
||||
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a> · <a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClawを作った理由
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
|
||||
|
||||
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
```bash
|
||||
gh repo fork qwibitai/nanoclaw --clone
|
||||
cd nanoclaw
|
||||
claude
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>GitHub CLIなしの場合</summary>
|
||||
|
||||
1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック)
|
||||
2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git`
|
||||
3. `cd nanoclaw`
|
||||
4. `claude`
|
||||
|
||||
</details>
|
||||
|
||||
その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。
|
||||
|
||||
> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。
|
||||
|
||||
## 設計思想
|
||||
|
||||
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。
|
||||
|
||||
**分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。
|
||||
|
||||
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。
|
||||
|
||||
**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。
|
||||
|
||||
**AIネイティブ。**
|
||||
- インストールウィザードなし — Claude Codeがセットアップを案内。
|
||||
- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。
|
||||
- デバッグツールなし — 問題を説明すればClaudeが修正。
|
||||
|
||||
**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
|
||||
**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。
|
||||
|
||||
## サポート機能
|
||||
|
||||
- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。
|
||||
- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。
|
||||
- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。
|
||||
- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。
|
||||
- **Webアクセス** - Webからのコンテンツ検索・取得。
|
||||
- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。
|
||||
- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。
|
||||
- **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。
|
||||
|
||||
## 使い方
|
||||
|
||||
トリガーワード(デフォルト:`@Andy`)でアシスタントに話しかけます:
|
||||
|
||||
```
|
||||
@Andy 毎朝9時に営業パイプラインの概要を送って(Obsidian vaultフォルダにアクセス可能)
|
||||
@Andy 毎週金曜に過去1週間のgit履歴をレビューして、差異があればREADMEを更新して
|
||||
@Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って
|
||||
```
|
||||
|
||||
メインチャネル(セルフチャット)から、グループやタスクを管理できます:
|
||||
```
|
||||
@Andy 全グループのスケジュールタスクを一覧表示して
|
||||
@Andy 月曜のブリーフィングタスクを一時停止して
|
||||
@Andy Family Chatグループに参加して
|
||||
```
|
||||
|
||||
## カスタマイズ
|
||||
|
||||
NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです:
|
||||
|
||||
- 「トリガーワードを@Bobに変更して」
|
||||
- 「今後はレスポンスをもっと短く直接的にして」
|
||||
- 「おはようと言ったらカスタム挨拶を追加して」
|
||||
- 「会話の要約を毎週保存して」
|
||||
|
||||
または`/customize`を実行してガイド付きの変更を行えます。
|
||||
|
||||
コードベースは十分に小さいため、Claudeが安全に変更できます。
|
||||
|
||||
## コントリビューション
|
||||
|
||||
**機能を追加するのではなく、スキルを追加してください。**
|
||||
|
||||
Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。
|
||||
|
||||
ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
|
||||
### RFS(スキル募集)
|
||||
|
||||
私たちが求めているスキル:
|
||||
|
||||
**コミュニケーションチャネル**
|
||||
- `/add-signal` - Signalをチャネルとして追加
|
||||
|
||||
**セッション管理**
|
||||
- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。
|
||||
|
||||
## 必要条件
|
||||
|
||||
- macOSまたはLinux
|
||||
- Node.js 20以上
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux)
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス
|
||||
```
|
||||
|
||||
単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。
|
||||
|
||||
詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。
|
||||
|
||||
主要ファイル:
|
||||
- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し
|
||||
- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録)
|
||||
- `src/ipc.ts` - IPCウォッチャーとタスク処理
|
||||
- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング
|
||||
- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー
|
||||
- `src/container-runner.ts` - ストリーミングエージェントコンテナの起動
|
||||
- `src/task-scheduler.ts` - スケジュールタスクの実行
|
||||
- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態)
|
||||
- `groups/*/CLAUDE.md` - グループごとのメモリ
|
||||
|
||||
## FAQ
|
||||
|
||||
**なぜDockerなのか?**
|
||||
|
||||
Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。
|
||||
|
||||
**Linuxで実行できますか?**
|
||||
|
||||
はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。
|
||||
|
||||
**セキュリティは大丈夫ですか?**
|
||||
|
||||
エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。
|
||||
|
||||
**なぜ設定ファイルがないのか?**
|
||||
|
||||
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。
|
||||
|
||||
**サードパーティやオープンソースモデルを使えますか?**
|
||||
|
||||
はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
以下が使用可能です:
|
||||
- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル
|
||||
- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル
|
||||
- Anthropic互換APIのカスタムモデルデプロイメント
|
||||
|
||||
注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。
|
||||
|
||||
**問題のデバッグ方法は?**
|
||||
|
||||
Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。
|
||||
|
||||
**セットアップがうまくいかない場合は?**
|
||||
|
||||
問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。
|
||||
|
||||
**どのような変更がコードベースに受け入れられますか?**
|
||||
|
||||
セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。
|
||||
|
||||
それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。
|
||||
|
||||
これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。
|
||||
|
||||
## コミュニティ
|
||||
|
||||
質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。
|
||||
|
||||
## 変更履歴
|
||||
|
||||
破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT
|
||||
61
README_zh.md
61
README_zh.md
@@ -9,17 +9,19 @@
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
通过 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 +33,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 +105,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 +120,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 +140,6 @@ WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) -->
|
||||
|
||||
## FAQ
|
||||
|
||||
**为什么是 WhatsApp 而不是 Telegram/Signal 等?**
|
||||
|
||||
因为我用 WhatsApp。Fork 这个项目然后运行一个技能来改变它。正是这个项目的核心理念。
|
||||
|
||||
**为什么是 Docker?**
|
||||
|
||||
Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。
|
||||
@@ -159,13 +156,29 @@ Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在
|
||||
|
||||
我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。
|
||||
|
||||
**我可以使用第三方或开源模型吗?**
|
||||
|
||||
可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
这使您能够使用:
|
||||
- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
|
||||
- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
|
||||
- 兼容 Anthropic API 格式的自定义模型部署
|
||||
|
||||
注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
|
||||
|
||||
**我该如何调试问题?**
|
||||
|
||||
问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。
|
||||
|
||||
**为什么我的安装不成功?**
|
||||
|
||||
我不知道。运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 `SKILL.md` 安装文件。
|
||||
如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。
|
||||
|
||||
**什么样的代码更改会被接受?**
|
||||
|
||||
@@ -179,6 +192,10 @@ Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在
|
||||
|
||||
有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。
|
||||
|
||||
## 更新日志
|
||||
|
||||
破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
@@ -7,6 +7,7 @@ FROM node:22-slim
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
libgbm1 \
|
||||
libnss3 \
|
||||
@@ -51,7 +52,8 @@ RUN npm run build
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# Create entrypoint script
|
||||
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||
# Container input (prompt, group info) is passed via stdin JSON.
|
||||
# Credentials are injected by the host's credential proxy — never passed here.
|
||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user