feat: skills as branches, channels as forks
Replace the custom skills engine with standard git operations. Feature skills are now git branches (on upstream or channel forks) applied via `git merge`. Channels are separate fork repos. - Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts - Remove old skill format (add/, modify/, manifest.yaml) from all skills - Remove old CI (skill-drift.yml, skill-pr.yml) - Add merge-forward CI for upstream skill branches - Add fork notification (repository_dispatch to channel forks) - Add marketplace config (.claude/settings.json) - Add /update-skills operational skill - Update /setup and /customize for marketplace plugin install - Add docs/skills-as-branches.md architecture doc Channel forks created: nanoclaw-whatsapp (with 5 skill branches), nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail. Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,206 +0,0 @@
|
||||
# Add Discord Channel
|
||||
|
||||
This skill adds Discord 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 `discord` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion` to collect configuration:
|
||||
|
||||
AskUserQuestion: Do you have a Discord bot token, or do you need to create one?
|
||||
|
||||
If they have one, collect it now. If not, we'll create one in Phase 3.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
|
||||
```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-discord
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
|
||||
- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock)
|
||||
- Appends `import './discord.js'` to the channel barrel file `src/channels/index.ts`
|
||||
- Installs the `discord.js` npm dependency
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
|
||||
If the apply reports merge conflicts, read the intent file:
|
||||
- `modify/src/channels/index.ts.intent.md` — what changed and invariants
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
All tests must pass (including the new Discord tests) and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
### Create Discord Bot (if needed)
|
||||
|
||||
If the user doesn't have a bot token, tell them:
|
||||
|
||||
> I need you to create a Discord bot:
|
||||
>
|
||||
> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
> 2. Click **New Application** and give it a name (e.g., "Andy Assistant")
|
||||
> 3. Go to the **Bot** tab on the left sidebar
|
||||
> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once)
|
||||
> 5. Under **Privileged Gateway Intents**, enable:
|
||||
> - **Message Content Intent** (required to read message text)
|
||||
> - **Server Members Intent** (optional, for member display names)
|
||||
> 6. Go to **OAuth2** > **URL Generator**:
|
||||
> - Scopes: select `bot`
|
||||
> - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels`
|
||||
> - Copy the generated URL and open it in your browser to invite the bot to your server
|
||||
|
||||
Wait for the user to provide the token.
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=<their-token>
|
||||
```
|
||||
|
||||
Channels auto-enable when their credentials are present — no extra configuration needed.
|
||||
|
||||
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:
|
||||
|
||||
> To get the channel ID for registration:
|
||||
>
|
||||
> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode**
|
||||
> 2. Right-click the text channel you want the bot to respond in
|
||||
> 3. Click **Copy Channel ID**
|
||||
>
|
||||
> The channel ID will be a long number like `1234567890123456`.
|
||||
|
||||
Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
|
||||
|
||||
### Register the channel
|
||||
|
||||
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
|
||||
|
||||
For a main channel (responds to all messages):
|
||||
|
||||
```typescript
|
||||
registerGroup("dc:<channel-id>", {
|
||||
name: "<server-name> #<channel-name>",
|
||||
folder: "discord_main",
|
||||
trigger: `@${ASSISTANT_NAME}`,
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
isMain: true,
|
||||
});
|
||||
```
|
||||
|
||||
For additional channels (trigger-only):
|
||||
|
||||
```typescript
|
||||
registerGroup("dc:<channel-id>", {
|
||||
name: "<server-name> #<channel-name>",
|
||||
folder: "discord_<channel-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 Discord channel:
|
||||
> - For main channel: Any message works
|
||||
> - For non-main: @mention the bot in Discord
|
||||
>
|
||||
> The bot should respond within a few seconds.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Check `DISCORD_BOT_TOKEN` is 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 'dc:%'"`
|
||||
3. For non-main channels: message must include trigger pattern (@mention the bot)
|
||||
4. Service is running: `launchctl list | grep nanoclaw`
|
||||
5. Verify the bot has been invited to the server (check OAuth2 URL was used)
|
||||
|
||||
### Bot only responds to @mentions
|
||||
|
||||
This is the default behavior for non-main channels (`requiresTrigger: true`). To change:
|
||||
- Update the registered group's `requiresTrigger` to `false`
|
||||
- Or register the channel as the main channel
|
||||
|
||||
### Message Content Intent not enabled
|
||||
|
||||
If the bot connects but can't read messages, ensure:
|
||||
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Select your application > **Bot** tab
|
||||
3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
|
||||
4. Restart NanoClaw
|
||||
|
||||
### Getting Channel ID
|
||||
|
||||
If you can't copy the channel ID:
|
||||
- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode
|
||||
- Right-click the channel name in the server sidebar > Copy Channel ID
|
||||
|
||||
## After Setup
|
||||
|
||||
The Discord bot supports:
|
||||
- Text messages in registered channels
|
||||
- Attachment descriptions (images, videos, files shown as placeholders)
|
||||
- Reply context (shows who the user is replying to)
|
||||
- @mention translation (Discord `<@botId>` → NanoClaw trigger format)
|
||||
- Message splitting for responses over 2000 characters
|
||||
- Typing indicators while the agent processes
|
||||
@@ -1,776 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock registry (registerChannel runs at import time)
|
||||
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
|
||||
|
||||
// Mock env reader (used by the factory, not needed in unit tests)
|
||||
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- discord.js mock ---
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
const Events = {
|
||||
MessageCreate: 'messageCreate',
|
||||
ClientReady: 'ready',
|
||||
Error: 'error',
|
||||
};
|
||||
|
||||
const GatewayIntentBits = {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 4,
|
||||
DirectMessages: 8,
|
||||
};
|
||||
|
||||
class MockClient {
|
||||
eventHandlers = new Map<string, Handler[]>();
|
||||
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||
private _ready = false;
|
||||
|
||||
constructor(_opts: any) {
|
||||
clientRef.current = this;
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
const existing = this.eventHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
this.eventHandlers.set(event, existing);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: Handler) {
|
||||
return this.on(event, handler);
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
this._ready = true;
|
||||
// Fire the ready event
|
||||
const readyHandlers = this.eventHandlers.get('ready') || [];
|
||||
for (const h of readyHandlers) {
|
||||
h({ user: this.user });
|
||||
}
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
channels = {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this._ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock TextChannel type
|
||||
class TextChannel {}
|
||||
|
||||
return {
|
||||
Client: MockClient,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
TextChannel,
|
||||
};
|
||||
});
|
||||
|
||||
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(
|
||||
overrides?: Partial<DiscordChannelOpts>,
|
||||
): DiscordChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'dc:1234567890123456': {
|
||||
name: 'Test Server #general',
|
||||
folder: 'test-server',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: {
|
||||
channelId?: string;
|
||||
content?: string;
|
||||
authorId?: string;
|
||||
authorUsername?: string;
|
||||
authorDisplayName?: string;
|
||||
memberDisplayName?: string;
|
||||
isBot?: boolean;
|
||||
guildName?: string;
|
||||
channelName?: string;
|
||||
messageId?: string;
|
||||
createdAt?: Date;
|
||||
attachments?: Map<string, any>;
|
||||
reference?: { messageId?: string };
|
||||
mentionsBotId?: boolean;
|
||||
}) {
|
||||
const channelId = overrides.channelId ?? '1234567890123456';
|
||||
const authorId = overrides.authorId ?? '55512345';
|
||||
const botId = '999888777'; // matches mock client user id
|
||||
|
||||
const mentionsMap = new Map();
|
||||
if (overrides.mentionsBotId) {
|
||||
mentionsMap.set(botId, { id: botId });
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
id: overrides.messageId ?? 'msg_001',
|
||||
content: overrides.content ?? 'Hello everyone',
|
||||
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
|
||||
author: {
|
||||
id: authorId,
|
||||
username: overrides.authorUsername ?? 'alice',
|
||||
displayName: overrides.authorDisplayName ?? 'Alice',
|
||||
bot: overrides.isBot ?? false,
|
||||
},
|
||||
member: overrides.memberDisplayName
|
||||
? { displayName: overrides.memberDisplayName }
|
||||
: null,
|
||||
guild: overrides.guildName
|
||||
? { name: overrides.guildName }
|
||||
: null,
|
||||
channel: {
|
||||
name: overrides.channelName ?? 'general',
|
||||
messages: {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
author: { username: 'Bob', displayName: 'Bob' },
|
||||
member: { displayName: 'Bob' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
mentions: {
|
||||
users: mentionsMap,
|
||||
},
|
||||
attachments: overrides.attachments ?? new Map(),
|
||||
reference: overrides.reference ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function currentClient() {
|
||||
return clientRef.current;
|
||||
}
|
||||
|
||||
async function triggerMessage(message: any) {
|
||||
const handlers = currentClient().eventHandlers.get('messageCreate') || [];
|
||||
for (const h of handlers) await h(message);
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('DiscordChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('resolves connect() when client is ready', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('registers message handlers on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
|
||||
expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
|
||||
expect(currentClient().eventHandlers.has('error')).toBe(true);
|
||||
expect(currentClient().eventHandlers.has('ready')).toBe(true);
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected() returns false before connect', () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text message handling ---
|
||||
|
||||
describe('text message handling', () => {
|
||||
it('delivers message for registered channel', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello everyone',
|
||||
guildName: 'Test Server',
|
||||
channelName: 'general',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'Test Server #general',
|
||||
'discord',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
id: 'msg_001',
|
||||
chat_jid: 'dc:1234567890123456',
|
||||
sender: '55512345',
|
||||
sender_name: 'Alice',
|
||||
content: 'Hello everyone',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only emits metadata for unregistered channels', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
channelId: '9999999999999999',
|
||||
content: 'Unknown channel',
|
||||
guildName: 'Other Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:9999999999999999',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'discord',
|
||||
true,
|
||||
);
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores bot messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({ isBot: true, content: 'I am a bot' });
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses member displayName when available (server nickname)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hi',
|
||||
memberDisplayName: 'Alice Nickname',
|
||||
authorDisplayName: 'Alice Global',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({ sender_name: 'Alice Nickname' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to author displayName when no member', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hi',
|
||||
memberDisplayName: undefined,
|
||||
authorDisplayName: 'Alice Global',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({ sender_name: 'Alice Global' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses sender name for DM chats (no guild)', async () => {
|
||||
const opts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'dc:1234567890123456': {
|
||||
name: 'DM',
|
||||
folder: 'dm',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello',
|
||||
guildName: undefined,
|
||||
authorDisplayName: 'Alice',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'Alice',
|
||||
'discord',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses guild name + channel name for server messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'Hello',
|
||||
guildName: 'My Server',
|
||||
channelName: 'bot-chat',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.any(String),
|
||||
'My Server #bot-chat',
|
||||
'discord',
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- @mention translation ---
|
||||
|
||||
describe('@mention translation', () => {
|
||||
it('translates <@botId> mention to trigger format', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '<@999888777> what time is it?',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy what time is it?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate if message already matches trigger', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '@Andy hello <@999888777>',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
// Should NOT prepend @Andy — already starts with trigger
|
||||
// But the <@botId> should still be stripped
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy hello',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not translate when bot is not mentioned', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'hello everyone',
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: 'hello everyone',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles <@!botId> (nickname mention format)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: '<@!999888777> check this',
|
||||
mentionsBotId: true,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '@Andy check this',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
describe('attachments', () => {
|
||||
it('stores image attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'photo.png', contentType: 'image/png' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Image: photo.png]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores video attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Video: clip.mp4]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores file attachment with placeholder', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[File: report.pdf]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes text content with attachments', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: 'Check this out',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: 'Check this out\n[Image: photo.jpg]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple attachments', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const attachments = new Map([
|
||||
['att1', { name: 'a.png', contentType: 'image/png' }],
|
||||
['att2', { name: 'b.txt', contentType: 'text/plain' }],
|
||||
]);
|
||||
const msg = createMessage({
|
||||
content: '',
|
||||
attachments,
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Image: a.png]\n[File: b.txt]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reply context ---
|
||||
|
||||
describe('reply context', () => {
|
||||
it('includes reply author in content', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
content: 'I agree with that',
|
||||
reference: { messageId: 'original_msg_id' },
|
||||
guildName: 'Server',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: '[Reply to Bob] I agree with that',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- sendMessage ---
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('sends message via channel', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
||||
|
||||
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
|
||||
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
|
||||
});
|
||||
|
||||
it('strips dc: prefix from JID', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.sendMessage('dc:9876543210', 'Test');
|
||||
|
||||
expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
|
||||
});
|
||||
|
||||
it('handles send failure gracefully', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
currentClient().channels.fetch.mockRejectedValueOnce(
|
||||
new Error('Channel not found'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
channel.sendMessage('dc:1234567890123456', 'Will fail'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when client is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
// Don't connect — client is null
|
||||
await channel.sendMessage('dc:1234567890123456', 'No client');
|
||||
|
||||
// No error, no API call
|
||||
});
|
||||
|
||||
it('splits messages exceeding 2000 characters', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
const longText = 'x'.repeat(3000);
|
||||
await channel.sendMessage('dc:1234567890123456', longText);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledTimes(2);
|
||||
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
|
||||
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
it('owns dc: JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not own WhatsApp group JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own Telegram JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('tg:123456789')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not own unknown JID formats', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends typing indicator when isTyping is true', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const mockChannel = {
|
||||
send: vi.fn(),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.setTyping('dc:1234567890123456', true);
|
||||
|
||||
expect(mockChannel.sendTyping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when isTyping is false', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
await channel.setTyping('dc:1234567890123456', false);
|
||||
|
||||
// channels.fetch should NOT be called
|
||||
expect(currentClient().channels.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when client is not initialized', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
// Don't connect
|
||||
await channel.setTyping('dc:1234567890123456', true);
|
||||
|
||||
// No error
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
it('has name "discord"', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.name).toBe('discord');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
|
||||
|
||||
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { registerChannel, ChannelOpts } from './registry.js';
|
||||
import {
|
||||
Channel,
|
||||
OnChatMetadata,
|
||||
OnInboundMessage,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
export interface DiscordChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class DiscordChannel implements Channel {
|
||||
name = 'discord';
|
||||
|
||||
private client: Client | null = null;
|
||||
private opts: DiscordChannelOpts;
|
||||
private botToken: string;
|
||||
|
||||
constructor(botToken: string, opts: DiscordChannelOpts) {
|
||||
this.botToken = botToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
});
|
||||
|
||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||
// Ignore bot messages (including own)
|
||||
if (message.author.bot) return;
|
||||
|
||||
const channelId = message.channelId;
|
||||
const chatJid = `dc:${channelId}`;
|
||||
let content = message.content;
|
||||
const timestamp = message.createdAt.toISOString();
|
||||
const senderName =
|
||||
message.member?.displayName ||
|
||||
message.author.displayName ||
|
||||
message.author.username;
|
||||
const sender = message.author.id;
|
||||
const msgId = message.id;
|
||||
|
||||
// Determine chat name
|
||||
let chatName: string;
|
||||
if (message.guild) {
|
||||
const textChannel = message.channel as TextChannel;
|
||||
chatName = `${message.guild.name} #${textChannel.name}`;
|
||||
} else {
|
||||
chatName = senderName;
|
||||
}
|
||||
|
||||
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
|
||||
// Discord mentions look like <@botUserId> — these won't match
|
||||
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
|
||||
// when the bot is @mentioned.
|
||||
if (this.client?.user) {
|
||||
const botId = this.client.user.id;
|
||||
const isBotMentioned =
|
||||
message.mentions.users.has(botId) ||
|
||||
content.includes(`<@${botId}>`) ||
|
||||
content.includes(`<@!${botId}>`);
|
||||
|
||||
if (isBotMentioned) {
|
||||
// Strip the <@botId> mention to avoid visual clutter
|
||||
content = content
|
||||
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
|
||||
.trim();
|
||||
// Prepend trigger if not already present
|
||||
if (!TRIGGER_PATTERN.test(content)) {
|
||||
content = `@${ASSISTANT_NAME} ${content}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments — store placeholders so the agent knows something was sent
|
||||
if (message.attachments.size > 0) {
|
||||
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
|
||||
const contentType = att.contentType || '';
|
||||
if (contentType.startsWith('image/')) {
|
||||
return `[Image: ${att.name || 'image'}]`;
|
||||
} else if (contentType.startsWith('video/')) {
|
||||
return `[Video: ${att.name || 'video'}]`;
|
||||
} else if (contentType.startsWith('audio/')) {
|
||||
return `[Audio: ${att.name || 'audio'}]`;
|
||||
} else {
|
||||
return `[File: ${att.name || 'file'}]`;
|
||||
}
|
||||
});
|
||||
if (content) {
|
||||
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
||||
} else {
|
||||
content = attachmentDescriptions.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reply context — include who the user is replying to
|
||||
if (message.reference?.messageId) {
|
||||
try {
|
||||
const repliedTo = await message.channel.messages.fetch(
|
||||
message.reference.messageId,
|
||||
);
|
||||
const replyAuthor =
|
||||
repliedTo.member?.displayName ||
|
||||
repliedTo.author.displayName ||
|
||||
repliedTo.author.username;
|
||||
content = `[Reply to ${replyAuthor}] ${content}`;
|
||||
} catch {
|
||||
// Referenced message may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Store chat metadata for discovery
|
||||
const isGroup = message.guild !== null;
|
||||
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
|
||||
|
||||
// Only deliver full message for registered groups
|
||||
const group = this.opts.registeredGroups()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug(
|
||||
{ chatJid, chatName },
|
||||
'Message from unregistered Discord channel',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deliver message — startMessageLoop() will pick it up
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msgId,
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ chatJid, chatName, sender: senderName },
|
||||
'Discord message stored',
|
||||
);
|
||||
});
|
||||
|
||||
// Handle errors gracefully
|
||||
this.client.on(Events.Error, (err) => {
|
||||
logger.error({ err: err.message }, 'Discord client error');
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.client!.once(Events.ClientReady, (readyClient) => {
|
||||
logger.info(
|
||||
{ username: readyClient.user.tag, id: readyClient.user.id },
|
||||
'Discord bot connected',
|
||||
);
|
||||
console.log(`\n Discord bot: ${readyClient.user.tag}`);
|
||||
console.log(
|
||||
` Use /chatid command or check channel IDs in Discord settings\n`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client!.login(this.botToken);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(jid: string, text: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.warn('Discord client not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
|
||||
if (!channel || !('send' in channel)) {
|
||||
logger.warn({ jid }, 'Discord channel not found or not text-based');
|
||||
return;
|
||||
}
|
||||
|
||||
const textChannel = channel as TextChannel;
|
||||
|
||||
// Discord has a 2000 character limit per message — split if needed
|
||||
const MAX_LENGTH = 2000;
|
||||
if (text.length <= MAX_LENGTH) {
|
||||
await textChannel.send(text);
|
||||
} else {
|
||||
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||
await textChannel.send(text.slice(i, i + MAX_LENGTH));
|
||||
}
|
||||
}
|
||||
logger.info({ jid, length: text.length }, 'Discord message sent');
|
||||
} catch (err) {
|
||||
logger.error({ jid, err }, 'Failed to send Discord message');
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && this.client.isReady();
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid.startsWith('dc:');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.destroy();
|
||||
this.client = null;
|
||||
logger.info('Discord bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
if (!this.client || !isTyping) return;
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (channel && 'sendTyping' in channel) {
|
||||
await (channel as TextChannel).sendTyping();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerChannel('discord', (opts: ChannelOpts) => {
|
||||
const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
|
||||
const token =
|
||||
process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || '';
|
||||
if (!token) {
|
||||
logger.warn('Discord: DISCORD_BOT_TOKEN not set');
|
||||
return null;
|
||||
}
|
||||
return new DiscordChannel(token, opts);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
skill: discord
|
||||
version: 1.0.0
|
||||
description: "Discord Bot integration via discord.js"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- src/channels/discord.ts
|
||||
- src/channels/discord.test.ts
|
||||
modifies:
|
||||
- src/channels/index.ts
|
||||
structured:
|
||||
npm_dependencies:
|
||||
discord.js: "^14.18.0"
|
||||
env_additions:
|
||||
- DISCORD_BOT_TOKEN
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npx vitest run src/channels/discord.test.ts"
|
||||
@@ -1,13 +0,0 @@
|
||||
// Channel self-registration barrel file.
|
||||
// Each import triggers the channel module's registerChannel() call.
|
||||
|
||||
// discord
|
||||
import './discord.js';
|
||||
|
||||
// gmail
|
||||
|
||||
// slack
|
||||
|
||||
// telegram
|
||||
|
||||
// whatsapp
|
||||
@@ -1,7 +0,0 @@
|
||||
# Intent: Add Discord channel import
|
||||
|
||||
Add `import './discord.js';` to the channel barrel file so the Discord
|
||||
module self-registers with the channel registry on startup.
|
||||
|
||||
This is an append-only change — existing import lines for other channels
|
||||
must be preserved.
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('discord skill package', () => {
|
||||
const skillDir = path.resolve(__dirname, '..');
|
||||
|
||||
it('has a valid manifest', () => {
|
||||
const manifestPath = path.join(skillDir, 'manifest.yaml');
|
||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8');
|
||||
expect(content).toContain('skill: discord');
|
||||
expect(content).toContain('version: 1.0.0');
|
||||
expect(content).toContain('discord.js');
|
||||
});
|
||||
|
||||
it('has all files declared in adds', () => {
|
||||
const channelFile = path.join(
|
||||
skillDir,
|
||||
'add',
|
||||
'src',
|
||||
'channels',
|
||||
'discord.ts',
|
||||
);
|
||||
expect(fs.existsSync(channelFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(channelFile, 'utf-8');
|
||||
expect(content).toContain('class DiscordChannel');
|
||||
expect(content).toContain('implements Channel');
|
||||
expect(content).toContain("registerChannel('discord'");
|
||||
|
||||
// Test file for the channel
|
||||
const testFile = path.join(
|
||||
skillDir,
|
||||
'add',
|
||||
'src',
|
||||
'channels',
|
||||
'discord.test.ts',
|
||||
);
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const testContent = fs.readFileSync(testFile, 'utf-8');
|
||||
expect(testContent).toContain("describe('DiscordChannel'");
|
||||
});
|
||||
|
||||
it('has all files declared in modifies', () => {
|
||||
// Channel barrel file
|
||||
const indexFile = path.join(
|
||||
skillDir,
|
||||
'modify',
|
||||
'src',
|
||||
'channels',
|
||||
'index.ts',
|
||||
);
|
||||
expect(fs.existsSync(indexFile)).toBe(true);
|
||||
|
||||
const indexContent = fs.readFileSync(indexFile, 'utf-8');
|
||||
expect(indexContent).toContain("import './discord.js'");
|
||||
});
|
||||
|
||||
it('has intent files for modified files', () => {
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user