From ee7f720617e12dc77cb725c381e88ed2d18e1e88 Mon Sep 17 00:00:00 2001 From: Darrell O'Donnell Date: Wed, 25 Feb 2026 05:34:34 -0800 Subject: [PATCH] /add-slack (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Slack channel skill (/add-slack) Slack Bot integration via @slack/bolt with Socket Mode. Can replace WhatsApp entirely (SLACK_ONLY=true) or run alongside it. - SlackChannel implementing Channel interface (46 unit tests) - Socket Mode connection (no public URL needed) - @mention translation (Slack <@UBOTID> → TRIGGER_PATTERN) - Message splitting at 4000-char Slack API limit - Thread flattening (threaded replies delivered as channel messages) - User name resolution with caching - Outgoing message queue with flush-on-reconnect - Channel metadata sync with pagination - Proper Bolt types (GenericMessageEvent | BotMessageEvent) - Multi-channel orchestrator changes (conditional channel creation) - Setup guide (SLACK_SETUP.md) and known limitations documented Co-Authored-By: Claude Opus 4.6 * local settings * adjusted when installing --------- Co-authored-by: Claude Opus 4.6 --- .claude/settings.local.json | 18 + .claude/skills/add-slack/SKILL.md | 227 +++++ .claude/skills/add-slack/SLACK_SETUP.md | 149 +++ .../add-slack/add/src/channels/slack.test.ts | 848 ++++++++++++++++++ .../add-slack/add/src/channels/slack.ts | 290 ++++++ .claude/skills/add-slack/manifest.yaml | 21 + .claude/skills/add-slack/modify/src/config.ts | 75 ++ .../add-slack/modify/src/config.ts.intent.md | 21 + .claude/skills/add-slack/modify/src/index.ts | 498 ++++++++++ .../add-slack/modify/src/index.ts.intent.md | 60 ++ .../add-slack/modify/src/routing.test.ts | 161 ++++ .../modify/src/routing.test.ts.intent.md | 17 + .claude/skills/add-slack/tests/slack.test.ts | 171 ++++ 13 files changed, 2556 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .claude/skills/add-slack/SKILL.md create mode 100644 .claude/skills/add-slack/SLACK_SETUP.md create mode 100644 .claude/skills/add-slack/add/src/channels/slack.test.ts create mode 100644 .claude/skills/add-slack/add/src/channels/slack.ts create mode 100644 .claude/skills/add-slack/manifest.yaml create mode 100644 .claude/skills/add-slack/modify/src/config.ts create mode 100644 .claude/skills/add-slack/modify/src/config.ts.intent.md create mode 100644 .claude/skills/add-slack/modify/src/index.ts create mode 100644 .claude/skills/add-slack/modify/src/index.ts.intent.md create mode 100644 .claude/skills/add-slack/modify/src/routing.test.ts create mode 100644 .claude/skills/add-slack/modify/src/routing.test.ts.intent.md create mode 100644 .claude/skills/add-slack/tests/slack.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c69f77d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(npm run build:*)", + "Bash(npm test:*)", + "Bash(git checkout:*)", + "Bash(npm install:*)", + "Bash(npx vitest run:*)", + "Bash(npx tsx:*)", + "Bash(node:*)", + "Bash(npm pack:*)", + "Bash(git status:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md new file mode 100644 index 0000000..3914bd9 --- /dev/null +++ b/.claude/skills/add-slack/SKILL.md @@ -0,0 +1,227 @@ +--- +name: add-slack +description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed). +--- + +# Add Slack Channel + +This skill adds Slack support to NanoClaw using the skills engine for deterministic code changes, 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. + +### 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. + +## 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: + +```bash +npx tsx scripts/apply-skill.ts --init +``` + +Or call `initSkillsSystem()` from `skills-engine/migrate.ts`. + +### Apply the skill + +```bash +npx tsx scripts/apply-skill.ts .claude/skills/add-slack +``` + +This deterministically: +- Adds `src/channels/slack.ts` (SlackChannel class implementing Channel interface) +- Adds `src/channels/slack.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` + +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 + +### Validate code changes + +```bash +npm test +npm run build +``` + +All tests must pass (including the new slack tests) and build must be clean before proceeding. + +## Phase 3: Setup + +### Create Slack App (if needed) + +If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table. + +Quick summary of what's needed: +1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) +2. Enable Socket Mode and generate an App-Level Token (`xapp-...`) +3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im` +4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read` +5. Install to workspace and copy the Bot Token (`xoxb-...`) + +Wait for the user to provide both tokens. + +### Configure environment + +Add to `.env`: + +```bash +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-token +``` + +If they chose to replace WhatsApp: + +```bash +SLACK_ONLY=true +``` + +Sync to container environment: + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +The container reads environment from `data/env/env`, not `.env` directly. + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +## Phase 4: Registration + +### Get Channel ID + +Tell the user: + +> 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**) +> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID +> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment +> +> The JID format for NanoClaw is: `slack:C0123456789` + +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. + +For a main channel (responds to all messages, uses the `main` folder): + +```typescript +registerGroup("slack:", { + name: "", + folder: "main", + trigger: `@${ASSISTANT_NAME}`, + added_at: new Date().toISOString(), + requiresTrigger: false, +}); +``` + +For additional channels (trigger-only): + +```typescript +registerGroup("slack:", { + name: "", + folder: "", + trigger: `@${ASSISTANT_NAME}`, + added_at: new Date().toISOString(), + requiresTrigger: true, +}); +``` + +## Phase 5: Verify + +### Test the connection + +Tell the user: + +> Send a message in your registered Slack channel: +> - For main channel: Any message works +> - For non-main: `@ hello` (using the configured trigger word) +> +> The bot should respond within a few seconds. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log +``` + +## Troubleshooting + +### Bot not responding + +1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env` +2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"` +3. For non-main channels: message must include trigger pattern +4. Service is running: `launchctl list | grep nanoclaw` + +### Bot connected but not receiving messages + +1. Verify Socket Mode is enabled in the Slack app settings +2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`) +3. Verify the bot has been added to the channel +4. Check that the bot has the required OAuth scopes + +### Bot not seeing messages in channels + +By default, bots only see messages in channels they've been explicitly added to. Make sure to: +1. Add the bot to each channel you want it to monitor +2. Check the bot has `channels:history` and/or `groups:history` scopes + +### "missing_scope" errors + +If the bot logs `missing_scope` errors: +1. Go to **OAuth & Permissions** in your Slack app settings +2. Add the missing scope listed in the error message +3. **Reinstall the app** to your workspace — scope changes require reinstallation +4. Copy the new Bot Token (it changes on reinstall) and update `.env` +5. Sync: `mkdir -p data/env && cp .env data/env/env` +6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` + +### Getting channel ID + +If the channel ID is hard to find: +- In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL +- In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789` +- Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'` + +## After Setup + +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`) + +## Known Limitations + +- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic. +- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works. +- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability. +- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent. +- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup. +- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section. diff --git a/.claude/skills/add-slack/SLACK_SETUP.md b/.claude/skills/add-slack/SLACK_SETUP.md new file mode 100644 index 0000000..90e2041 --- /dev/null +++ b/.claude/skills/add-slack/SLACK_SETUP.md @@ -0,0 +1,149 @@ +# 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` diff --git a/.claude/skills/add-slack/add/src/channels/slack.test.ts b/.claude/skills/add-slack/add/src/channels/slack.test.ts new file mode 100644 index 0000000..4c841d1 --- /dev/null +++ b/.claude/skills/add-slack/add/src/channels/slack.test.ts @@ -0,0 +1,848 @@ +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(); + 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 { + 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) { + 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'); + }); + }); +}); diff --git a/.claude/skills/add-slack/add/src/channels/slack.ts b/.claude/skills/add-slack/add/src/channels/slack.ts new file mode 100644 index 0000000..81cc1ac --- /dev/null +++ b/.claude/skills/add-slack/add/src/channels/slack.ts @@ -0,0 +1,290 @@ +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; +} + +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(); + + 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., ^@\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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/.claude/skills/add-slack/manifest.yaml b/.claude/skills/add-slack/manifest.yaml new file mode 100644 index 0000000..8320bb3 --- /dev/null +++ b/.claude/skills/add-slack/manifest.yaml @@ -0,0 +1,21 @@ +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" diff --git a/.claude/skills/add-slack/modify/src/config.ts b/.claude/skills/add-slack/modify/src/config.ts new file mode 100644 index 0000000..1b59cf7 --- /dev/null +++ b/.claude/skills/add-slack/modify/src/config.ts @@ -0,0 +1,75 @@ +import path from 'path'; + +import { readEnvFile } from './env.js'; + +// Read config values from .env (falls back to process.env). +// Secrets are NOT read here — they stay on disk and are loaded only +// where needed (container-runner.ts) to avoid leaking to child processes. +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'SLACK_ONLY', +]); + +export const ASSISTANT_NAME = + process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_HAS_OWN_NUMBER = + (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; +export const POLL_INTERVAL = 2000; +export const SCHEDULER_POLL_INTERVAL = 60000; + +// Absolute paths needed for container mounts +const PROJECT_ROOT = process.cwd(); +const HOME_DIR = process.env.HOME || '/Users/user'; + +// Mount security: allowlist stored OUTSIDE project root, never mounted into containers +export const MOUNT_ALLOWLIST_PATH = path.join( + HOME_DIR, + '.config', + 'nanoclaw', + 'mount-allowlist.json', +); +export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); +export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); +export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); +export const MAIN_GROUP_FOLDER = 'main'; + +export const CONTAINER_IMAGE = + process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt( + process.env.CONTAINER_TIMEOUT || '1800000', + 10, +); +export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( + process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', + 10, +); // 10MB default +export const IPC_POLL_INTERVAL = 1000; +export const IDLE_TIMEOUT = parseInt( + process.env.IDLE_TIMEOUT || '1800000', + 10, +); // 30min default — how long to keep container alive after last result +export const MAX_CONCURRENT_CONTAINERS = Math.max( + 1, + parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5, +); + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export const TRIGGER_PATTERN = new RegExp( + `^@${escapeRegex(ASSISTANT_NAME)}\\b`, + 'i', +); + +// Timezone for scheduled tasks (cron expressions, etc.) +// Uses system timezone by default +export const TIMEZONE = + process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Slack configuration +// SLACK_BOT_TOKEN and SLACK_APP_TOKEN are read directly by SlackChannel +// from .env via readEnvFile() to keep secrets off process.env. +export const SLACK_ONLY = + (process.env.SLACK_ONLY || envConfig.SLACK_ONLY) === 'true'; diff --git a/.claude/skills/add-slack/modify/src/config.ts.intent.md b/.claude/skills/add-slack/modify/src/config.ts.intent.md new file mode 100644 index 0000000..b23def4 --- /dev/null +++ b/.claude/skills/add-slack/modify/src/config.ts.intent.md @@ -0,0 +1,21 @@ +# Intent: src/config.ts modifications + +## What changed +Added SLACK_ONLY configuration export for Slack channel support. + +## Key sections +- **readEnvFile call**: Must include `SLACK_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`. +- **SLACK_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation +- **Note**: SLACK_BOT_TOKEN and SLACK_APP_TOKEN are NOT read here. They are read directly by SlackChannel via `readEnvFile()` in `slack.ts` to keep secrets off the config module entirely (same pattern as ANTHROPIC_API_KEY in container-runner.ts). + +## Invariants +- All existing config exports remain unchanged +- New Slack key is added to the `readEnvFile` call alongside existing keys +- New export is appended at the end of the file +- No existing behavior is modified — Slack config is additive only +- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`) + +## Must-keep +- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.) +- The `readEnvFile` pattern — ALL config read from `.env` must go through this function +- The `escapeRegex` helper and `TRIGGER_PATTERN` construction diff --git a/.claude/skills/add-slack/modify/src/index.ts b/.claude/skills/add-slack/modify/src/index.ts new file mode 100644 index 0000000..50212e1 --- /dev/null +++ b/.claude/skills/add-slack/modify/src/index.ts @@ -0,0 +1,498 @@ +import fs from 'fs'; +import path from 'path'; + +import { + ASSISTANT_NAME, + DATA_DIR, + IDLE_TIMEOUT, + MAIN_GROUP_FOLDER, + POLL_INTERVAL, + SLACK_ONLY, + TRIGGER_PATTERN, +} from './config.js'; +import { WhatsAppChannel } from './channels/whatsapp.js'; +import { SlackChannel } from './channels/slack.js'; +import { + ContainerOutput, + runContainerAgent, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; +import { + getAllChats, + getAllRegisteredGroups, + getAllSessions, + getAllTasks, + getMessagesSince, + getNewMessages, + getRouterState, + initDatabase, + setRegisteredGroup, + setRouterState, + setSession, + storeChatMetadata, + storeMessage, +} from './db.js'; +import { GroupQueue } from './group-queue.js'; +import { startIpcWatcher } from './ipc.js'; +import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { startSchedulerLoop } from './task-scheduler.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; +import { logger } from './logger.js'; +import { readEnvFile } from './env.js'; + +// Re-export for backwards compatibility during refactor +export { escapeXml, formatMessages } from './router.js'; + +let lastTimestamp = ''; +let sessions: Record = {}; +let registeredGroups: Record = {}; +let lastAgentTimestamp: Record = {}; +let messageLoopRunning = false; + +let whatsapp: WhatsAppChannel; +let slack: SlackChannel | undefined; +const channels: Channel[] = []; +const queue = new GroupQueue(); + +function loadState(): void { + lastTimestamp = getRouterState('last_timestamp') || ''; + const agentTs = getRouterState('last_agent_timestamp'); + try { + lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; + } catch { + logger.warn('Corrupted last_agent_timestamp in DB, resetting'); + lastAgentTimestamp = {}; + } + sessions = getAllSessions(); + registeredGroups = getAllRegisteredGroups(); + logger.info( + { groupCount: Object.keys(registeredGroups).length }, + 'State loaded', + ); +} + +function saveState(): void { + setRouterState('last_timestamp', lastTimestamp); + setRouterState( + 'last_agent_timestamp', + JSON.stringify(lastAgentTimestamp), + ); +} + +function registerGroup(jid: string, group: RegisteredGroup): void { + registeredGroups[jid] = group; + setRegisteredGroup(jid, group); + + // Create group folder + const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder); + fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + + logger.info( + { jid, name: group.name, folder: group.folder }, + 'Group registered', + ); +} + +/** + * Get available groups list for the agent. + * Returns groups ordered by most recent activity. + */ +export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { + const chats = getAllChats(); + const registeredJids = new Set(Object.keys(registeredGroups)); + + return chats + .filter((c) => c.jid !== '__group_sync__' && c.is_group) + .map((c) => ({ + jid: c.jid, + name: c.name, + lastActivity: c.last_message_time, + isRegistered: registeredJids.has(c.jid), + })); +} + +/** @internal - exported for testing */ +export function _setRegisteredGroups(groups: Record): void { + registeredGroups = groups; +} + +/** + * Process all pending messages for a group. + * Called by the GroupQueue when it's this group's turn. + */ +async function processGroupMessages(chatJid: string): Promise { + const group = registeredGroups[chatJid]; + if (!group) return true; + + const channel = findChannel(channels, chatJid); + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + return true; + } + + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; + + const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + + if (missedMessages.length === 0) return true; + + // For non-main groups, check if trigger is required and present + if (!isMainGroup && group.requiresTrigger !== false) { + const hasTrigger = missedMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), + ); + if (!hasTrigger) return true; + } + + const prompt = formatMessages(missedMessages); + + // Advance cursor so the piping path in startMessageLoop won't re-fetch + // these messages. Save the old cursor so we can roll back on error. + const previousCursor = lastAgentTimestamp[chatJid] || ''; + lastAgentTimestamp[chatJid] = + missedMessages[missedMessages.length - 1].timestamp; + saveState(); + + logger.info( + { group: group.name, messageCount: missedMessages.length }, + 'Processing messages', + ); + + // Track idle timer for closing stdin when agent is idle + let idleTimer: ReturnType | null = null; + + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + logger.debug({ group: group.name }, 'Idle timeout, closing container stdin'); + queue.closeStdin(chatJid); + }, IDLE_TIMEOUT); + }; + + await channel.setTyping?.(chatJid, true); + let hadError = false; + let outputSentToUser = false; + + const output = await runAgent(group, prompt, chatJid, async (result) => { + // Streaming output callback — called for each agent result + if (result.result) { + const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + // Strip ... blocks — agent uses these for internal reasoning + const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); + logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); + if (text) { + await channel.sendMessage(chatJid, text); + outputSentToUser = true; + } + // Only reset idle timer on actual results, not session-update markers (result: null) + resetIdleTimer(); + } + + if (result.status === 'error') { + hadError = true; + } + }); + + await channel.setTyping?.(chatJid, false); + if (idleTimer) clearTimeout(idleTimer); + + if (output === 'error' || hadError) { + // If we already sent output to the user, don't roll back the cursor — + // the user got their response and re-processing would send duplicates. + if (outputSentToUser) { + logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates'); + return true; + } + // Roll back cursor so retries can re-process these messages + lastAgentTimestamp[chatJid] = previousCursor; + saveState(); + logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry'); + return false; + } + + return true; +} + +async function runAgent( + group: RegisteredGroup, + prompt: string, + chatJid: string, + onOutput?: (output: ContainerOutput) => Promise, +): Promise<'success' | 'error'> { + const isMain = group.folder === MAIN_GROUP_FOLDER; + const sessionId = sessions[group.folder]; + + // Update tasks snapshot for container to read (filtered by group) + const tasks = getAllTasks(); + writeTasksSnapshot( + group.folder, + isMain, + tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run, + })), + ); + + // Update available groups snapshot (main group only can see all groups) + const availableGroups = getAvailableGroups(); + writeGroupsSnapshot( + group.folder, + isMain, + availableGroups, + new Set(Object.keys(registeredGroups)), + ); + + // Wrap onOutput to track session ID from streamed results + const wrappedOnOutput = onOutput + ? async (output: ContainerOutput) => { + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + await onOutput(output); + } + : undefined; + + try { + const output = await runContainerAgent( + group, + { + prompt, + sessionId, + groupFolder: group.folder, + chatJid, + isMain, + }, + (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), + wrappedOnOutput, + ); + + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + + if (output.status === 'error') { + logger.error( + { group: group.name, error: output.error }, + 'Container agent error', + ); + return 'error'; + } + + return 'success'; + } catch (err) { + logger.error({ group: group.name, err }, 'Agent error'); + return 'error'; + } +} + +async function startMessageLoop(): Promise { + if (messageLoopRunning) { + logger.debug('Message loop already running, skipping duplicate start'); + return; + } + messageLoopRunning = true; + + logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + + while (true) { + try { + const jids = Object.keys(registeredGroups); + const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); + + if (messages.length > 0) { + logger.info({ count: messages.length }, 'New messages'); + + // Advance the "seen" cursor for all messages immediately + lastTimestamp = newTimestamp; + saveState(); + + // Deduplicate by group + const messagesByGroup = new Map(); + for (const msg of messages) { + const existing = messagesByGroup.get(msg.chat_jid); + if (existing) { + existing.push(msg); + } else { + messagesByGroup.set(msg.chat_jid, [msg]); + } + } + + for (const [chatJid, groupMessages] of messagesByGroup) { + const group = registeredGroups[chatJid]; + if (!group) continue; + + const channel = findChannel(channels, chatJid); + if (!channel) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + continue; + } + + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; + const needsTrigger = !isMainGroup && group.requiresTrigger !== false; + + // For non-main groups, only act on trigger messages. + // Non-trigger messages accumulate in DB and get pulled as + // context when a trigger eventually arrives. + if (needsTrigger) { + const hasTrigger = groupMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), + ); + if (!hasTrigger) continue; + } + + // Pull all messages since lastAgentTimestamp so non-trigger + // context that accumulated between triggers is included. + const allPending = getMessagesSince( + chatJid, + lastAgentTimestamp[chatJid] || '', + ASSISTANT_NAME, + ); + const messagesToSend = + allPending.length > 0 ? allPending : groupMessages; + const formatted = formatMessages(messagesToSend); + + if (queue.sendMessage(chatJid, formatted)) { + logger.debug( + { chatJid, count: messagesToSend.length }, + 'Piped messages to active container', + ); + lastAgentTimestamp[chatJid] = + messagesToSend[messagesToSend.length - 1].timestamp; + saveState(); + // Show typing indicator while the container processes the piped message + channel.setTyping?.(chatJid, true); + } else { + // No active container — enqueue for a new one + queue.enqueueMessageCheck(chatJid); + } + } + } + } catch (err) { + logger.error({ err }, 'Error in message loop'); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); + } +} + +/** + * Startup recovery: check for unprocessed messages in registered groups. + * Handles crash between advancing lastTimestamp and processing messages. + */ +function recoverPendingMessages(): void { + for (const [chatJid, group] of Object.entries(registeredGroups)) { + const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + if (pending.length > 0) { + logger.info( + { group: group.name, pendingCount: pending.length }, + 'Recovery: found unprocessed messages', + ); + queue.enqueueMessageCheck(chatJid); + } + } +} + +function ensureContainerSystemRunning(): void { + ensureContainerRuntimeRunning(); + cleanupOrphans(); +} + +async function main(): Promise { + ensureContainerSystemRunning(); + initDatabase(); + logger.info('Database initialized'); + loadState(); + + // Graceful shutdown handlers + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutdown signal received'); + await queue.shutdown(10000); + for (const ch of channels) await ch.disconnect(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Channel callbacks (shared by all channels) + const channelOpts = { + onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg), + onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => + storeChatMetadata(chatJid, timestamp, name, channel, isGroup), + registeredGroups: () => registeredGroups, + }; + + // Create and connect channels + // Check if Slack tokens are configured + const slackEnv = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']); + const hasSlackTokens = !!(slackEnv.SLACK_BOT_TOKEN && slackEnv.SLACK_APP_TOKEN); + + if (!SLACK_ONLY) { + whatsapp = new WhatsAppChannel(channelOpts); + channels.push(whatsapp); + await whatsapp.connect(); + } + + if (hasSlackTokens) { + slack = new SlackChannel(channelOpts); + channels.push(slack); + await slack.connect(); + } + + // Start subsystems (independently of connection handler) + startSchedulerLoop({ + registeredGroups: () => registeredGroups, + getSessions: () => sessions, + queue, + onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), + sendMessage: async (jid, rawText) => { + const channel = findChannel(channels, jid); + if (!channel) { + console.log(`Warning: no channel owns JID ${jid}, cannot send message`); + return; + } + const text = formatOutbound(rawText); + if (text) await channel.sendMessage(jid, text); + }, + }); + startIpcWatcher({ + sendMessage: (jid, text) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No channel for JID: ${jid}`); + return channel.sendMessage(jid, text); + }, + registeredGroups: () => registeredGroups, + registerGroup, + syncGroupMetadata: async (force) => { + // Sync metadata across all active channels + if (whatsapp) await whatsapp.syncGroupMetadata(force); + if (slack) await slack.syncChannelMetadata(); + }, + getAvailableGroups, + writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), + }); + queue.setProcessMessagesFn(processGroupMessages); + recoverPendingMessages(); + startMessageLoop(); +} + +// Guard: only run when executed directly, not when imported by tests +const isDirectRun = + process.argv[1] && + new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; + +if (isDirectRun) { + main().catch((err) => { + logger.error({ err }, 'Failed to start NanoClaw'); + process.exit(1); + }); +} diff --git a/.claude/skills/add-slack/modify/src/index.ts.intent.md b/.claude/skills/add-slack/modify/src/index.ts.intent.md new file mode 100644 index 0000000..8412843 --- /dev/null +++ b/.claude/skills/add-slack/modify/src/index.ts.intent.md @@ -0,0 +1,60 @@ +# Intent: src/index.ts modifications + +## What changed +Refactored from single WhatsApp channel to multi-channel architecture supporting Slack alongside WhatsApp. + +## Key sections + +### Imports (top of file) +- Added: `SlackChannel` from `./channels/slack.js` +- Added: `SLACK_ONLY` from `./config.js` +- Added: `readEnvFile` from `./env.js` +- Existing: `findChannel` from `./router.js` and `Channel` type from `./types.js` are already present + +### Module-level state +- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference +- Added: `let slack: SlackChannel | undefined` — direct reference for `syncChannelMetadata` +- Kept: `const channels: Channel[] = []` — array of all active channels + +### processGroupMessages() +- Uses `findChannel(channels, chatJid)` lookup (already exists in base) +- Uses `channel.setTyping?.()` and `channel.sendMessage()` (already exists in base) + +### startMessageLoop() +- Uses `findChannel(channels, chatJid)` per group (already exists in base) +- Uses `channel.setTyping?.()` for typing indicators (already exists in base) + +### main() +- Added: Reads Slack tokens via `readEnvFile()` to check if Slack is configured +- Added: conditional WhatsApp creation (`if (!SLACK_ONLY)`) +- Added: conditional Slack creation (`if (hasSlackTokens)`) +- Changed: scheduler `sendMessage` uses `findChannel()` → `channel.sendMessage()` +- Changed: IPC `syncGroupMetadata` syncs both WhatsApp and Slack metadata +- Changed: IPC `sendMessage` uses `findChannel()` → `channel.sendMessage()` + +### Shutdown handler +- Changed from `await whatsapp.disconnect()` to `for (const ch of channels) await ch.disconnect()` +- Disconnects all active channels (WhatsApp, Slack, or any future channels) on SIGTERM/SIGINT + +## Invariants +- All existing message processing logic (triggers, cursors, idle timers) is preserved +- The `runAgent` function is completely unchanged +- State management (loadState/saveState) is unchanged +- Recovery logic is unchanged +- Container runtime check is unchanged (ensureContainerSystemRunning) + +## Design decisions + +### Double readEnvFile for Slack tokens +`main()` in index.ts reads `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` via `readEnvFile()` to check +whether Slack is configured (controls whether to instantiate SlackChannel). The SlackChannel +constructor reads them again independently. This is intentional — index.ts needs to decide +*whether* to create the channel, while SlackChannel needs the actual token values. Keeping +both reads follows the security pattern of not passing secrets through intermediate variables. + +## Must-keep +- The `escapeXml` and `formatMessages` re-exports +- The `_setRegisteredGroups` test helper +- The `isDirectRun` guard at bottom +- All error handling and cursor rollback logic in processGroupMessages +- The outgoing queue flush and reconnection logic (in each channel, not here) diff --git a/.claude/skills/add-slack/modify/src/routing.test.ts b/.claude/skills/add-slack/modify/src/routing.test.ts new file mode 100644 index 0000000..3a7f7ff --- /dev/null +++ b/.claude/skills/add-slack/modify/src/routing.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js'; +import { getAvailableGroups, _setRegisteredGroups } from './index.js'; + +beforeEach(() => { + _initTestDatabase(); + _setRegisteredGroups({}); +}); + +// --- JID ownership patterns --- + +describe('JID ownership patterns', () => { + // These test the patterns that will become ownsJid() on the Channel interface + + it('WhatsApp group JID: ends with @g.us', () => { + const jid = '12345678@g.us'; + expect(jid.endsWith('@g.us')).toBe(true); + }); + + it('WhatsApp DM JID: ends with @s.whatsapp.net', () => { + const jid = '12345678@s.whatsapp.net'; + expect(jid.endsWith('@s.whatsapp.net')).toBe(true); + }); + + it('Slack channel JID: starts with slack:', () => { + const jid = 'slack:C0123456789'; + expect(jid.startsWith('slack:')).toBe(true); + }); + + it('Slack DM JID: starts with slack:D', () => { + const jid = 'slack:D0123456789'; + expect(jid.startsWith('slack:')).toBe(true); + }); +}); + +// --- getAvailableGroups --- + +describe('getAvailableGroups', () => { + it('returns only groups, excludes DMs', () => { + storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true); + storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false); + storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.jid)).toContain('group1@g.us'); + expect(groups.map((g) => g.jid)).toContain('group2@g.us'); + expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net'); + }); + + it('excludes __group_sync__ sentinel', () => { + storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z'); + storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('group@g.us'); + }); + + it('marks registered groups correctly', () => { + storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true); + storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true); + + _setRegisteredGroups({ + 'reg@g.us': { + name: 'Registered', + folder: 'registered', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + }); + + const groups = getAvailableGroups(); + const reg = groups.find((g) => g.jid === 'reg@g.us'); + const unreg = groups.find((g) => g.jid === 'unreg@g.us'); + + expect(reg?.isRegistered).toBe(true); + expect(unreg?.isRegistered).toBe(false); + }); + + it('returns groups ordered by most recent activity', () => { + storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true); + storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true); + storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups[0].jid).toBe('new@g.us'); + expect(groups[1].jid).toBe('mid@g.us'); + expect(groups[2].jid).toBe('old@g.us'); + }); + + it('excludes non-group chats regardless of JID format', () => { + // Unknown JID format stored without is_group should not appear + storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown'); + // Explicitly non-group with unusual JID + storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false); + // A real group for contrast + storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('group@g.us'); + }); + + it('returns empty array when no chats exist', () => { + const groups = getAvailableGroups(); + expect(groups).toHaveLength(0); + }); + + it('includes Slack channel JIDs', () => { + storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Channel', 'slack', true); + storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('slack:C0123456789'); + }); + + it('returns Slack DM JIDs as groups when is_group is true', () => { + storeChatMetadata('slack:D0123456789', '2024-01-01T00:00:01.000Z', 'Slack DM', 'slack', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('slack:D0123456789'); + expect(groups[0].name).toBe('Slack DM'); + }); + + it('marks registered Slack channels correctly', () => { + storeChatMetadata('slack:C0123456789', '2024-01-01T00:00:01.000Z', 'Slack Registered', 'slack', true); + storeChatMetadata('slack:C9999999999', '2024-01-01T00:00:02.000Z', 'Slack Unregistered', 'slack', true); + + _setRegisteredGroups({ + 'slack:C0123456789': { + name: 'Slack Registered', + folder: 'slack-registered', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + }); + + const groups = getAvailableGroups(); + const slackReg = groups.find((g) => g.jid === 'slack:C0123456789'); + const slackUnreg = groups.find((g) => g.jid === 'slack:C9999999999'); + + expect(slackReg?.isRegistered).toBe(true); + expect(slackUnreg?.isRegistered).toBe(false); + }); + + it('mixes WhatsApp and Slack chats ordered by activity', () => { + storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true); + storeChatMetadata('slack:C100', '2024-01-01T00:00:03.000Z', 'Slack', 'slack', true); + storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(3); + expect(groups[0].jid).toBe('slack:C100'); + expect(groups[1].jid).toBe('wa2@g.us'); + expect(groups[2].jid).toBe('wa@g.us'); + }); +}); diff --git a/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md b/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md new file mode 100644 index 0000000..a03ba99 --- /dev/null +++ b/.claude/skills/add-slack/modify/src/routing.test.ts.intent.md @@ -0,0 +1,17 @@ +# Intent: src/routing.test.ts modifications + +## What changed +Added Slack JID pattern tests and Slack-specific getAvailableGroups tests. + +## Key sections +- **JID ownership patterns**: Added Slack channel JID (`slack:C...`) and Slack DM JID (`slack:D...`) pattern tests +- **getAvailableGroups**: Added tests for Slack channel inclusion, Slack DM handling, registered Slack channels, and mixed WhatsApp + Slack ordering + +## Invariants +- All existing WhatsApp JID pattern tests remain unchanged +- All existing getAvailableGroups tests remain unchanged +- New tests follow the same patterns as existing tests + +## Must-keep +- All existing WhatsApp tests (group JID, DM JID patterns) +- All existing getAvailableGroups tests (DM exclusion, sentinel exclusion, registration, ordering, non-group exclusion, empty array) diff --git a/.claude/skills/add-slack/tests/slack.test.ts b/.claude/skills/add-slack/tests/slack.test.ts new file mode 100644 index 0000000..7e8d946 --- /dev/null +++ b/.claude/skills/add-slack/tests/slack.test.ts @@ -0,0 +1,171 @@ +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'); + }); +});