/add-slack (#366)

* 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 <noreply@anthropic.com>

* local settings

* adjusted when installing

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darrell O'Donnell
2026-02-25 05:34:34 -08:00
committed by GitHub
parent bc05d5fbea
commit ee7f720617
13 changed files with 2556 additions and 0 deletions

View File

@@ -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:*)"
]
}
}

View File

@@ -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:<channel-id>", {
name: "<channel-name>",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
});
```
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,
});
```
## 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: `@<assistant-name> 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.

View File

@@ -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`

View File

@@ -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<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');
});
});
});

View File

@@ -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<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;
}
}
}

View File

@@ -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"

View File

@@ -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';

View File

@@ -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

View File

@@ -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<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);
});
}

View File

@@ -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)

View File

@@ -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');
});
});

View File

@@ -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)

View File

@@ -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');
});
});