Skills engine v0.1 + multi-channel infrastructure (#307)

* refactor: multi-channel infrastructure with explicit channel/is_group tracking

- Add channels[] array and findChannel() routing in index.ts, replacing
  hardcoded whatsapp.* calls with channel-agnostic callbacks
- Add channel TEXT and is_group INTEGER columns to chats table with
  COALESCE upsert to protect existing values from null overwrites
- is_group defaults to 0 (safe: unknown chats excluded from groups)
- WhatsApp passes explicit channel='whatsapp' and isGroup to onChatMetadata
- getAvailableGroups filters on is_group instead of JID pattern matching
- findChannel logs warnings instead of silently dropping unroutable JIDs
- Migration backfills channel/is_group from JID patterns for existing DBs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: skills engine v0.1 — deterministic skill packages with rerere resolution

Three-way merge engine for applying skill packages on top of a core
codebase. Skills declare which files they add/modify, and the engine
uses git merge-file for conflict detection with git rerere for
automatic resolution of previously-seen conflicts.

Key components:
- apply: three-way merge with backup/rollback safety net
- replay: clean-slate replay for uninstall and rebase
- update: core version updates with deletion detection
- rebase: bake applied skills into base (one-way)
- manifest: validation with path traversal protection
- resolution-cache: pre-computed rerere resolutions
- structured: npm deps, env vars, docker-compose merging
- CI: per-skill test matrix with conflict detection

151 unit tests covering merge, rerere, backup, replay, uninstall,
update, rebase, structured ops, and edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Discord and Telegram skill packages

Skill packages for adding Discord and Telegram channels to NanoClaw.
Each package includes:
- Channel implementation (add/src/channels/)
- Three-way merge targets for index.ts, config.ts, routing.test.ts
- Intent docs explaining merge invariants
- Standalone integration tests
- manifest.yaml with dependency/conflict declarations

Applied via: npx tsx scripts/apply-skill.ts .claude/skills/add-discord
These are inert until applied — no runtime impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* remove unused docs (skills-system-status, implementation-guide)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-19 01:55:00 +02:00
committed by GitHub
parent a689f8b3fa
commit 51788de3b9
83 changed files with 13159 additions and 626 deletions

View File

@@ -0,0 +1,213 @@
# 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
1. **Mode**: Replace WhatsApp or add alongside it?
- Replace → will set `DISCORD_ONLY=true`
- Alongside → both channels active (default)
2. **Do they already have a bot token?** If yes, collect it 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-discord
```
This deterministically:
- Adds `src/channels/discord.ts` (DiscordChannel class implementing Channel interface)
- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock)
- Three-way merges Discord support into `src/index.ts` (multi-channel support, findChannel routing)
- Three-way merges Discord config into `src/config.ts` (DISCORD_BOT_TOKEN, DISCORD_ONLY exports)
- Three-way merges updated routing tests into `src/routing.test.ts`
- Installs the `discord.js` npm dependency
- Updates `.env.example` with `DISCORD_BOT_TOKEN` and `DISCORD_ONLY`
- Records the application in `.nanoclaw/state.yaml`
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
### 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>
```
If they chose to replace WhatsApp:
```bash
DISCORD_ONLY=true
```
Sync to container environment:
```bash
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, uses the `main` folder):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
});
```
For additional channels (trigger-only):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "<folder-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```
## 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

View File

@@ -0,0 +1,762 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// --- discord.js mock ---
type Handler = (...args: any[]) => any;
const clientRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('discord.js', () => {
const Events = {
MessageCreate: 'messageCreate',
ClientReady: 'ready',
Error: 'error',
};
const GatewayIntentBits = {
Guilds: 1,
GuildMessages: 2,
MessageContent: 4,
DirectMessages: 8,
};
class MockClient {
eventHandlers = new Map<string, Handler[]>();
user: any = { id: '999888777', tag: 'Andy#1234' };
private _ready = false;
constructor(_opts: any) {
clientRef.current = this;
}
on(event: string, handler: Handler) {
const existing = this.eventHandlers.get(event) || [];
existing.push(handler);
this.eventHandlers.set(event, existing);
return this;
}
once(event: string, handler: Handler) {
return this.on(event, handler);
}
async login(_token: string) {
this._ready = true;
// Fire the ready event
const readyHandlers = this.eventHandlers.get('ready') || [];
for (const h of readyHandlers) {
h({ user: this.user });
}
}
isReady() {
return this._ready;
}
channels = {
fetch: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn().mockResolvedValue(undefined),
}),
};
destroy() {
this._ready = false;
}
}
// Mock TextChannel type
class TextChannel {}
return {
Client: MockClient,
Events,
GatewayIntentBits,
TextChannel,
};
});
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<DiscordChannelOpts>,
): DiscordChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'Test Server #general',
folder: 'test-server',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createMessage(overrides: {
channelId?: string;
content?: string;
authorId?: string;
authorUsername?: string;
authorDisplayName?: string;
memberDisplayName?: string;
isBot?: boolean;
guildName?: string;
channelName?: string;
messageId?: string;
createdAt?: Date;
attachments?: Map<string, any>;
reference?: { messageId?: string };
mentionsBotId?: boolean;
}) {
const channelId = overrides.channelId ?? '1234567890123456';
const authorId = overrides.authorId ?? '55512345';
const botId = '999888777'; // matches mock client user id
const mentionsMap = new Map();
if (overrides.mentionsBotId) {
mentionsMap.set(botId, { id: botId });
}
return {
channelId,
id: overrides.messageId ?? 'msg_001',
content: overrides.content ?? 'Hello everyone',
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
author: {
id: authorId,
username: overrides.authorUsername ?? 'alice',
displayName: overrides.authorDisplayName ?? 'Alice',
bot: overrides.isBot ?? false,
},
member: overrides.memberDisplayName
? { displayName: overrides.memberDisplayName }
: null,
guild: overrides.guildName
? { name: overrides.guildName }
: null,
channel: {
name: overrides.channelName ?? 'general',
messages: {
fetch: vi.fn().mockResolvedValue({
author: { username: 'Bob', displayName: 'Bob' },
member: { displayName: 'Bob' },
}),
},
},
mentions: {
users: mentionsMap,
},
attachments: overrides.attachments ?? new Map(),
reference: overrides.reference ?? null,
};
}
function currentClient() {
return clientRef.current;
}
async function triggerMessage(message: any) {
const handlers = currentClient().eventHandlers.get('messageCreate') || [];
for (const h of handlers) await h(message);
}
// --- Tests ---
describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when client is ready', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
expect(currentClient().eventHandlers.has('error')).toBe(true);
expect(currentClient().eventHandlers.has('ready')).toBe(true);
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Text message handling ---
describe('text message handling', () => {
it('delivers message for registered channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello everyone',
guildName: 'Test Server',
channelName: 'general',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Test Server #general',
);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
id: 'msg_001',
chat_jid: 'dc:1234567890123456',
sender: '55512345',
sender_name: 'Alice',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered channels', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
channelId: '9999999999999999',
content: 'Unknown channel',
guildName: 'Other Server',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:9999999999999999',
expect.any(String),
expect.any(String),
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores bot messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({ isBot: true, content: 'I am a bot' });
await triggerMessage(msg);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
});
it('uses member displayName when available (server nickname)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: 'Alice Nickname',
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Nickname' }),
);
});
it('falls back to author displayName when no member', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: undefined,
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Global' }),
);
});
it('uses sender name for DM chats (no guild)', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'DM',
folder: 'dm',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: undefined,
authorDisplayName: 'Alice',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Alice',
);
});
it('uses guild name + channel name for server messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: 'My Server',
channelName: 'bot-chat',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'My Server #bot-chat',
);
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('translates <@botId> mention to trigger format', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@999888777> what time is it?',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '@Andy hello <@999888777>',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
// Should NOT prepend @Andy — already starts with trigger
// But the <@botId> should still be stripped
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy hello',
}),
);
});
it('does not translate when bot is not mentioned', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'hello everyone',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'hello everyone',
}),
);
});
it('handles <@!botId> (nickname mention format)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@!999888777> check this',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy check this',
}),
);
});
});
// --- Attachments ---
describe('attachments', () => {
it('stores image attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.png', contentType: 'image/png' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: photo.png]',
}),
);
});
it('stores video attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Video: clip.mp4]',
}),
);
});
it('stores file attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[File: report.pdf]',
}),
);
});
it('includes text content with attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
]);
const msg = createMessage({
content: 'Check this out',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'Check this out\n[Image: photo.jpg]',
}),
);
});
it('handles multiple attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'a.png', contentType: 'image/png' }],
['att2', { name: 'b.txt', contentType: 'text/plain' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: a.png]\n[File: b.txt]',
}),
);
});
});
// --- Reply context ---
describe('reply context', () => {
it('includes reply author in content', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'I agree with that',
reference: { messageId: 'original_msg_id' },
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Reply to Bob] I agree with that',
}),
);
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:1234567890123456', 'Hello');
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
});
it('strips dc: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:9876543210', 'Test');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
});
it('handles send failure gracefully', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
currentClient().channels.fetch.mockRejectedValueOnce(
new Error('Channel not found'),
);
// Should not throw
await expect(
channel.sendMessage('dc:1234567890123456', 'Will fail'),
).resolves.toBeUndefined();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect — client is null
await channel.sendMessage('dc:1234567890123456', 'No client');
// No error, no API call
});
it('splits messages exceeding 2000 characters', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn(),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
const longText = 'x'.repeat(3000);
await channel.sendMessage('dc:1234567890123456', longText);
expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns dc: JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own Telegram JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:123456789')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- setTyping ---
describe('setTyping', () => {
it('sends typing indicator when isTyping is true', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn(),
sendTyping: vi.fn().mockResolvedValue(undefined),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.setTyping('dc:1234567890123456', true);
expect(mockChannel.sendTyping).toHaveBeenCalled();
});
it('does nothing when isTyping is false', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.setTyping('dc:1234567890123456', false);
// channels.fetch should NOT be called
expect(currentClient().channels.fetch).not.toHaveBeenCalled();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect
await channel.setTyping('dc:1234567890123456', true);
// No error
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "discord"', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.name).toBe('discord');
});
});
});

View File

@@ -0,0 +1,236 @@
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { logger } from '../logger.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface DiscordChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class DiscordChannel implements Channel {
name = 'discord';
private client: Client | null = null;
private opts: DiscordChannelOpts;
private botToken: string;
constructor(botToken: string, opts: DiscordChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
this.client.on(Events.MessageCreate, async (message: Message) => {
// Ignore bot messages (including own)
if (message.author.bot) return;
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
message.member?.displayName ||
message.author.displayName ||
message.author.username;
const sender = message.author.id;
const msgId = message.id;
// Determine chat name
let chatName: string;
if (message.guild) {
const textChannel = message.channel as TextChannel;
chatName = `${message.guild.name} #${textChannel.name}`;
} else {
chatName = senderName;
}
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
// Discord mentions look like <@botUserId> — these won't match
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
// when the bot is @mentioned.
if (this.client?.user) {
const botId = this.client.user.id;
const isBotMentioned =
message.mentions.users.has(botId) ||
content.includes(`<@${botId}>`) ||
content.includes(`<@!${botId}>`);
if (isBotMentioned) {
// Strip the <@botId> mention to avoid visual clutter
content = content
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
.trim();
// Prepend trigger if not already present
if (!TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
}
// Handle attachments — store placeholders so the agent knows something was sent
if (message.attachments.size > 0) {
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
const contentType = att.contentType || '';
if (contentType.startsWith('image/')) {
return `[Image: ${att.name || 'image'}]`;
} else if (contentType.startsWith('video/')) {
return `[Video: ${att.name || 'video'}]`;
} else if (contentType.startsWith('audio/')) {
return `[Audio: ${att.name || 'audio'}]`;
} else {
return `[File: ${att.name || 'file'}]`;
}
});
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
content = attachmentDescriptions.join('\n');
}
}
// Handle reply context — include who the user is replying to
if (message.reference?.messageId) {
try {
const repliedTo = await message.channel.messages.fetch(
message.reference.messageId,
);
const replyAuthor =
repliedTo.member?.displayName ||
repliedTo.author.displayName ||
repliedTo.author.username;
content = `[Reply to ${replyAuthor}] ${content}`;
} catch {
// Referenced message may have been deleted
}
}
// Store chat metadata for discovery
this.opts.onChatMetadata(chatJid, timestamp, chatName);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Discord channel',
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Discord message stored',
);
});
// Handle errors gracefully
this.client.on(Events.Error, (err) => {
logger.error({ err: err.message }, 'Discord client error');
});
return new Promise<void>((resolve) => {
this.client!.once(Events.ClientReady, (readyClient) => {
logger.info(
{ username: readyClient.user.tag, id: readyClient.user.id },
'Discord bot connected',
);
console.log(`\n Discord bot: ${readyClient.user.tag}`);
console.log(
` Use /chatid command or check channel IDs in Discord settings\n`,
);
resolve();
});
this.client!.login(this.botToken);
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.client) {
logger.warn('Discord client not initialized');
return;
}
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) {
logger.warn({ jid }, 'Discord channel not found or not text-based');
return;
}
const textChannel = channel as TextChannel;
// Discord has a 2000 character limit per message — split if needed
const MAX_LENGTH = 2000;
if (text.length <= MAX_LENGTH) {
await textChannel.send(text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await textChannel.send(text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, 'Discord message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Discord message');
}
}
isConnected(): boolean {
return this.client !== null && this.client.isReady();
}
ownsJid(jid: string): boolean {
return jid.startsWith('dc:');
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();
this.client = null;
logger.info('Discord bot stopped');
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.client || !isTyping) return;
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (channel && 'sendTyping' in channel) {
await (channel as TextChannel).sendTyping();
}
} catch (err) {
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
}
}
}

View File

@@ -0,0 +1,20 @@
skill: discord
version: 1.0.0
description: "Discord Bot integration via discord.js"
core_version: 0.1.0
adds:
- src/channels/discord.ts
- src/channels/discord.test.ts
modifies:
- src/index.ts
- src/config.ts
- src/routing.test.ts
structured:
npm_dependencies:
discord.js: "^14.18.0"
env_additions:
- DISCORD_BOT_TOKEN
- DISCORD_ONLY
conflicts: []
depends: []
test: "npx vitest run src/channels/discord.test.ts"

View File

@@ -0,0 +1,76 @@
import path from 'path';
import { readEnvFile } from './env.js';
// Read config values from .env (falls back to process.env).
// Secrets are NOT read here — they stay on disk and are loaded only
// where needed (container-runner.ts) to avoid leaking to child processes.
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'DISCORD_BOT_TOKEN',
'DISCORD_ONLY',
]);
export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
// Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || '/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;
// Discord configuration
export const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN || envConfig.DISCORD_BOT_TOKEN || '';
export const DISCORD_ONLY =
(process.env.DISCORD_ONLY || envConfig.DISCORD_ONLY) === 'true';

View File

@@ -0,0 +1,21 @@
# Intent: src/config.ts modifications
## What changed
Added two new configuration exports for Discord channel support.
## Key sections
- **readEnvFile call**: Must include `DISCORD_BOT_TOKEN` and `DISCORD_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
- **DISCORD_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
- **DISCORD_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
## Invariants
- All existing config exports remain unchanged
- New Discord keys are added to the `readEnvFile` call alongside existing keys
- New exports are appended at the end of the file
- No existing behavior is modified — Discord config is additive only
- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
## Must-keep
- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
- The `escapeRegex` helper and `TRIGGER_PATTERN` construction

View File

@@ -0,0 +1,537 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
DATA_DIR,
DISCORD_BOT_TOKEN,
DISCORD_ONLY,
IDLE_TIMEOUT,
MAIN_GROUP_FOLDER,
POLL_INTERVAL,
TRIGGER_PATTERN,
} from './config.js';
import { DiscordChannel } from './channels/discord.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import {
ContainerOutput,
runContainerAgent,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.js';
import {
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';
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
let messageLoopRunning = false;
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
try {
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
} catch {
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups();
logger.info(
{ groupCount: Object.keys(registeredGroups).length },
'State loaded',
);
}
function saveState(): void {
setRouterState('last_timestamp', lastTimestamp);
setRouterState(
'last_agent_timestamp',
JSON.stringify(lastAgentTimestamp),
);
}
function registerGroup(jid: string, group: RegisteredGroup): void {
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) 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) 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 {
try {
execSync('container system status', { stdio: 'pipe' });
logger.debug('Apple Container system already running');
} catch {
logger.info('Starting Apple Container system...');
try {
execSync('container system start', { stdio: 'pipe', timeout: 30000 });
logger.info('Apple Container system started');
} catch (err) {
logger.error({ err }, 'Failed to start Apple Container system');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Apple Container system failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without Apple Container. To fix: ║',
);
console.error(
'║ 1. Install from: https://github.com/apple/container/releases ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Apple Container system is required but failed to start');
}
}
// Kill and clean up orphaned NanoClaw containers from previous runs
try {
const output = execSync('container ls --format json', {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
execSync(`container stop ${name}`, { stdio: 'pipe' });
} catch { /* already stopped */ }
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
// Create and connect channels
if (DISCORD_BOT_TOKEN) {
const discord = new DiscordChannel(DISCORD_BOT_TOKEN, channelOpts);
channels.push(discord);
await discord.connect();
}
if (!DISCORD_ONLY) {
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
}
// Start subsystems (independently of connection handler)
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const channel = findChannel(channels, jid);
if (!channel) return;
const text = formatOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
// 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,43 @@
# Intent: src/index.ts modifications
## What changed
Added Discord as a channel option alongside WhatsApp, introducing multi-channel infrastructure.
## Key sections
### Imports (top of file)
- Added: `DiscordChannel` from `./channels/discord.js`
- Added: `DISCORD_BOT_TOKEN`, `DISCORD_ONLY` from `./config.js`
- Added: `findChannel` from `./router.js`
- Added: `Channel` from `./types.js`
### Multi-channel infrastructure
- Added: `const channels: Channel[] = []` array to hold all active channels
- Changed: `processGroupMessages` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
- Changed: `startMessageLoop` uses `findChannel(channels, chatJid)` instead of `whatsapp` directly
- Changed: `channel.setTyping?.()` instead of `whatsapp.setTyping()`
- Changed: `channel.sendMessage()` instead of `whatsapp.sendMessage()`
### getAvailableGroups()
- Unchanged: uses `c.is_group` filter from base (Discord channels pass `isGroup=true` via `onChatMetadata`)
### main()
- Added: `channelOpts` shared callback object for all channels
- Changed: WhatsApp conditional to `if (!DISCORD_ONLY)`
- Added: conditional Discord creation (`if (DISCORD_BOT_TOKEN)`)
- Changed: shutdown iterates `channels` array instead of just `whatsapp`
- Changed: subsystems use `findChannel(channels, jid)` for message routing
## Invariants
- All existing message processing logic (triggers, cursors, idle timers) is preserved
- The `runAgent` function is completely unchanged
- State management (loadState/saveState) is unchanged
- Recovery logic is unchanged
- Apple Container check is unchanged (ensureContainerSystemRunning)
## Must-keep
- The `escapeXml` and `formatMessages` re-exports
- The `_setRegisteredGroups` test helper
- The `isDirectRun` guard at bottom
- All error handling and cursor rollback logic in processGroupMessages
- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
beforeEach(() => {
_initTestDatabase();
_setRegisteredGroups({});
});
// --- JID ownership patterns ---
describe('JID ownership patterns', () => {
// These test the patterns that will become ownsJid() on the Channel interface
it('WhatsApp group JID: ends with @g.us', () => {
const jid = '12345678@g.us';
expect(jid.endsWith('@g.us')).toBe(true);
});
it('Discord JID: starts with dc:', () => {
const jid = 'dc:1234567890123456';
expect(jid.startsWith('dc:')).toBe(true);
});
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
const jid = '12345678@s.whatsapp.net';
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
});
});
// --- getAvailableGroups ---
describe('getAvailableGroups', () => {
it('returns only groups, excludes DMs', () => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.jid)).toContain('group1@g.us');
expect(groups.map((g) => g.jid)).toContain('group2@g.us');
expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
});
it('includes Discord channel JIDs', () => {
storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'Discord Channel', 'discord', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('dc:1234567890123456');
});
it('marks registered Discord channels correctly', () => {
storeChatMetadata('dc:1234567890123456', '2024-01-01T00:00:01.000Z', 'DC Registered', 'discord', true);
storeChatMetadata('dc:9999999999999999', '2024-01-01T00:00:02.000Z', 'DC Unregistered', 'discord', true);
_setRegisteredGroups({
'dc:1234567890123456': {
name: 'DC Registered',
folder: 'dc-registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const dcReg = groups.find((g) => g.jid === 'dc:1234567890123456');
const dcUnreg = groups.find((g) => g.jid === 'dc:9999999999999999');
expect(dcReg?.isRegistered).toBe(true);
expect(dcUnreg?.isRegistered).toBe(false);
});
it('excludes __group_sync__ sentinel', () => {
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('marks registered groups correctly', () => {
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
_setRegisteredGroups({
'reg@g.us': {
name: 'Registered',
folder: 'registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const reg = groups.find((g) => g.jid === 'reg@g.us');
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
expect(reg?.isRegistered).toBe(true);
expect(unreg?.isRegistered).toBe(false);
});
it('returns groups ordered by most recent activity', () => {
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups[0].jid).toBe('new@g.us');
expect(groups[1].jid).toBe('mid@g.us');
expect(groups[2].jid).toBe('old@g.us');
});
it('excludes non-group chats regardless of JID format', () => {
// Unknown JID format stored without is_group should not appear
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
// Explicitly non-group with unusual JID
storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
// A real group for contrast
storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('returns empty array when no chats exist', () => {
const groups = getAvailableGroups();
expect(groups).toHaveLength(0);
});
it('mixes WhatsApp and Discord chats ordered by activity', () => {
storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
storeChatMetadata('dc:555', '2024-01-01T00:00:03.000Z', 'Discord', 'discord', true);
storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(3);
expect(groups[0].jid).toBe('dc:555');
expect(groups[1].jid).toBe('wa2@g.us');
expect(groups[2].jid).toBe('wa@g.us');
});
});

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('discord skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: discord');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('discord.js');
});
it('has all files declared in adds', () => {
const addFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.ts');
expect(fs.existsSync(addFile)).toBe(true);
const content = fs.readFileSync(addFile, 'utf-8');
expect(content).toContain('class DiscordChannel');
expect(content).toContain('implements Channel');
// Test file for the channel
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'discord.test.ts');
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('DiscordChannel'");
});
it('has all files declared in modifies', () => {
const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
expect(fs.existsSync(indexFile)).toBe(true);
expect(fs.existsSync(configFile)).toBe(true);
expect(fs.existsSync(routingTestFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain('DiscordChannel');
expect(indexContent).toContain('DISCORD_BOT_TOKEN');
expect(indexContent).toContain('DISCORD_ONLY');
expect(indexContent).toContain('findChannel');
expect(indexContent).toContain('channels: Channel[]');
const configContent = fs.readFileSync(configFile, 'utf-8');
expect(configContent).toContain('DISCORD_BOT_TOKEN');
expect(configContent).toContain('DISCORD_ONLY');
});
it('has intent files for modified files', () => {
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
});
it('modified index.ts preserves core structure', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'index.ts'),
'utf-8',
);
// Core functions still present
expect(content).toContain('function loadState()');
expect(content).toContain('function saveState()');
expect(content).toContain('function registerGroup(');
expect(content).toContain('function getAvailableGroups()');
expect(content).toContain('function processGroupMessages(');
expect(content).toContain('function runAgent(');
expect(content).toContain('function startMessageLoop()');
expect(content).toContain('function recoverPendingMessages()');
expect(content).toContain('function ensureContainerSystemRunning()');
expect(content).toContain('async function main()');
// Test helper preserved
expect(content).toContain('_setRegisteredGroups');
// Direct-run guard preserved
expect(content).toContain('isDirectRun');
});
it('modified index.ts includes Discord channel creation', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'index.ts'),
'utf-8',
);
// Multi-channel architecture
expect(content).toContain('const channels: Channel[] = []');
expect(content).toContain('channels.push(whatsapp)');
expect(content).toContain('channels.push(discord)');
// Conditional channel creation
expect(content).toContain('if (!DISCORD_ONLY)');
expect(content).toContain('if (DISCORD_BOT_TOKEN)');
// Shutdown disconnects all channels
expect(content).toContain('for (const ch of channels) await ch.disconnect()');
});
it('modified config.ts preserves all existing exports', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'config.ts'),
'utf-8',
);
// All original exports preserved
expect(content).toContain('export const ASSISTANT_NAME');
expect(content).toContain('export const POLL_INTERVAL');
expect(content).toContain('export const TRIGGER_PATTERN');
expect(content).toContain('export const CONTAINER_IMAGE');
expect(content).toContain('export const DATA_DIR');
expect(content).toContain('export const TIMEZONE');
// Discord exports added
expect(content).toContain('export const DISCORD_BOT_TOKEN');
expect(content).toContain('export const DISCORD_ONLY');
});
it('modified routing.test.ts includes Discord JID tests', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'routing.test.ts'),
'utf-8',
);
expect(content).toContain("Discord JID: starts with dc:");
expect(content).toContain("dc:1234567890123456");
expect(content).toContain("dc:");
});
});

View File

@@ -5,26 +5,70 @@ description: Add Telegram as a channel. Can replace WhatsApp entirely or run alo
# Add Telegram Channel
This skill adds Telegram support to NanoClaw. Users can choose to:
This skill adds Telegram support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
1. **Replace WhatsApp** - Use Telegram as the only messaging channel
2. **Add alongside WhatsApp** - Both channels active
3. **Control channel** - Telegram triggers agent but doesn't receive all outputs
4. **Notification channel** - Receives outputs but limited triggering
## Phase 1: Pre-flight
## Prerequisites
### Check if already applied
### 1. Install Grammy
Read `.nanoclaw/state.yaml`. If `telegram` 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 `TELEGRAM_ONLY=true`
- Alongside → both channels active (default)
2. **Do they already have a bot token?** If yes, collect it 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
npm install grammy
npx tsx scripts/apply-skill.ts --init
```
Grammy is a modern, TypeScript-first Telegram bot framework.
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### 2. Create Telegram Bot
### Apply the skill
Tell the user:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
```
This deterministically:
- Adds `src/channels/telegram.ts` (TelegramChannel class implementing Channel interface)
- Adds `src/channels/telegram.test.ts` (46 unit tests)
- Three-way merges Telegram support into `src/index.ts` (multi-channel support, findChannel routing)
- Three-way merges Telegram config into `src/config.ts` (TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY exports)
- Three-way merges updated routing tests into `src/routing.test.ts`
- Installs the `grammy` npm dependency
- Updates `.env.example` with `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY`
- Records the application in `.nanoclaw/state.yaml`
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
### Validate code changes
```bash
npm test
npm run build
```
All tests must pass (including the new telegram tests) and build must be clean before proceeding.
## Phase 3: Setup
### Create Telegram Bot (if needed)
If the user doesn't have a bot token, tell them:
> I need you to create a Telegram bot:
>
@@ -34,531 +78,92 @@ Tell the user:
> - Bot username: Must end with "bot" (e.g., "andy_ai_bot")
> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
Wait for user to provide the token.
Wait for the user to provide the token.
### 3. Get Chat ID
Tell the user:
> To register a chat, you need its Chat ID. Here's how:
>
> **For Private Chat (DM with bot):**
> 1. Search for your bot in Telegram
> 2. Start a chat and send any message
> 3. I'll add a `/chatid` command to help you get the ID
>
> **For Group Chat:**
> 1. Add your bot to the group
> 2. Send any message
> 3. Use the `/chatid` command in the group
### 4. Disable Group Privacy (for group chats)
Tell the user:
> **Important for group chats**: By default, Telegram bots in groups only receive messages that @mention the bot or are commands. To let the bot see all messages (needed for `requiresTrigger: false` or trigger-word detection):
>
> 1. Open Telegram and search for `@BotFather`
> 2. Send `/mybots` and select your bot
> 3. Go to **Bot Settings** > **Group Privacy**
> 4. Select **Turn off**
>
> Without this, the bot will only see messages that directly @mention it.
This step is optional if the user only wants trigger-based responses via @mentioning the bot.
## Questions to Ask
Before making changes, ask:
1. **Mode**: Replace WhatsApp or add alongside it?
- If replace: Set `TELEGRAM_ONLY=true`
- If alongside: Both will run
2. **Chat behavior**: Should this chat respond to all messages or only when @mentioned?
- Main chat: Responds to all (set `requiresTrigger: false`)
- Other chats: Default requires trigger (`requiresTrigger: true`)
## Architecture
NanoClaw uses a **Channel abstraction** (`Channel` interface in `src/types.ts`). Each messaging platform implements this interface. Key files:
| File | Purpose |
|------|---------|
| `src/types.ts` | `Channel` interface definition |
| `src/channels/whatsapp.ts` | `WhatsAppChannel` class (reference implementation) |
| `src/router.ts` | `findChannel()`, `routeOutbound()`, `formatOutbound()` |
| `src/index.ts` | Orchestrator: creates channels, wires callbacks, starts subsystems |
| `src/ipc.ts` | IPC watcher (uses `sendMessage` dep for outbound) |
The Telegram channel follows the same pattern as WhatsApp:
- Implements `Channel` interface (`connect`, `sendMessage`, `ownsJid`, `disconnect`, `setTyping`)
- Delivers inbound messages via `onMessage` / `onChatMetadata` callbacks
- The existing message loop in `src/index.ts` picks up stored messages automatically
## Implementation
### Step 1: Update Configuration
Read `src/config.ts` and add Telegram config exports:
```typescript
export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
export const TELEGRAM_ONLY = process.env.TELEGRAM_ONLY === "true";
```
These should be added near the top with other configuration exports.
### Step 2: Create Telegram Channel
Create `src/channels/telegram.ts` implementing the `Channel` interface. Use `src/channels/whatsapp.ts` as a reference for the pattern.
```typescript
import { Bot } from "grammy";
import {
ASSISTANT_NAME,
TRIGGER_PATTERN,
} from "../config.js";
import { logger } from "../logger.js";
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from "../types.js";
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class TelegramChannel implements Channel {
name = "telegram";
prefixAssistantName = false; // Telegram bots already display their name
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
// Command to get chat ID (useful for registration)
this.bot.command("chatid", (ctx) => {
const chatId = ctx.chat.id;
const chatType = ctx.chat.type;
const chatName =
chatType === "private"
? ctx.from?.first_name || "Private"
: (ctx.chat as any).title || "Unknown";
ctx.reply(
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
{ parse_mode: "Markdown" },
);
});
// Command to check bot status
this.bot.command("ping", (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
this.bot.on("message:text", async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith("/")) return;
const chatJid = `tg:${ctx.chat.id}`;
let content = ctx.message.text;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id.toString() ||
"Unknown";
const sender = ctx.from?.id.toString() || "";
const msgId = ctx.message.message_id.toString();
// Determine chat name
const chatName =
ctx.chat.type === "private"
? senderName
: (ctx.chat as any).title || chatJid;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
const botUsername = ctx.me?.username?.toLowerCase();
if (botUsername) {
const entities = ctx.message.entities || [];
const isBotMentioned = entities.some((entity) => {
if (entity.type === "mention") {
const mentionText = content
.substring(entity.offset, entity.offset + entity.length)
.toLowerCase();
return mentionText === `@${botUsername}`;
}
return false;
});
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
// Store chat metadata for discovery
this.opts.onChatMetadata(chatJid, timestamp, chatName);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
"Message from unregistered Telegram chat",
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
"Telegram message stored",
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
const storeNonText = (ctx: any, placeholder: string) => {
const chatJid = `tg:${ctx.chat.id}`;
const group = this.opts.registeredGroups()[chatJid];
if (!group) return;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name || ctx.from?.username || ctx.from?.id?.toString() || "Unknown";
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : "";
this.opts.onChatMetadata(chatJid, timestamp);
this.opts.onMessage(chatJid, {
id: ctx.message.message_id.toString(),
chat_jid: chatJid,
sender: ctx.from?.id?.toString() || "",
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
};
this.bot.on("message:photo", (ctx) => storeNonText(ctx, "[Photo]"));
this.bot.on("message:video", (ctx) => storeNonText(ctx, "[Video]"));
this.bot.on("message:voice", (ctx) => storeNonText(ctx, "[Voice message]"));
this.bot.on("message:audio", (ctx) => storeNonText(ctx, "[Audio]"));
this.bot.on("message:document", (ctx) => {
const name = ctx.message.document?.file_name || "file";
storeNonText(ctx, `[Document: ${name}]`);
});
this.bot.on("message:sticker", (ctx) => {
const emoji = ctx.message.sticker?.emoji || "";
storeNonText(ctx, `[Sticker ${emoji}]`);
});
this.bot.on("message:location", (ctx) => storeNonText(ctx, "[Location]"));
this.bot.on("message:contact", (ctx) => storeNonText(ctx, "[Contact]"));
// Handle errors gracefully
this.bot.catch((err) => {
logger.error({ err: err.message }, "Telegram bot error");
});
// Start polling — returns a Promise that resolves when started
return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => {
logger.info(
{ username: botInfo.username, id: botInfo.id },
"Telegram bot connected",
);
console.log(`\n Telegram bot: @${botInfo.username}`);
console.log(
` Send /chatid to the bot to get a chat's registration ID\n`,
);
resolve();
},
});
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.bot) {
logger.warn("Telegram bot not initialized");
return;
}
try {
const numericId = jid.replace(/^tg:/, "");
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, "Telegram message sent");
} catch (err) {
logger.error({ jid, err }, "Failed to send Telegram message");
}
}
isConnected(): boolean {
return this.bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith("tg:");
}
async disconnect(): Promise<void> {
if (this.bot) {
this.bot.stop();
this.bot = null;
logger.info("Telegram bot stopped");
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.bot || !isTyping) return;
try {
const numericId = jid.replace(/^tg:/, "");
await this.bot.api.sendChatAction(numericId, "typing");
} catch (err) {
logger.debug({ jid, err }, "Failed to send Telegram typing indicator");
}
}
}
```
Key differences from the old standalone `src/telegram.ts`:
- Implements `Channel` interface — same pattern as `WhatsAppChannel`
- Uses `onMessage` / `onChatMetadata` callbacks instead of importing DB functions directly
- Registration check via `registeredGroups()` callback, not `getAllRegisteredGroups()`
- `prefixAssistantName = false` — Telegram bots already show their name, so `formatOutbound()` skips the prefix
- No `storeMessageDirect` needed — `storeMessage()` in db.ts already accepts `NewMessage` directly
### Step 3: Update Main Application
Modify `src/index.ts` to support multiple channels. Read the file first to understand the current structure.
1. **Add imports** at the top:
```typescript
import { TelegramChannel } from "./channels/telegram.js";
import { TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY } from "./config.js";
import { findChannel } from "./router.js";
```
2. **Add a channels array** alongside the existing `whatsapp` variable:
```typescript
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
```
Import `Channel` from `./types.js` if not already imported.
3. **Update `processGroupMessages`** to find the correct channel for the JID instead of using `whatsapp` directly. Replace the direct `whatsapp.setTyping()` and `whatsapp.sendMessage()` calls:
```typescript
// Find the channel that owns this JID
const channel = findChannel(channels, chatJid);
if (!channel) return true; // No channel for this JID
// ... (existing code for message fetching, trigger check, formatting)
await channel.setTyping?.(chatJid, true);
// ... (existing agent invocation, replacing whatsapp.sendMessage with channel.sendMessage)
await channel.setTyping?.(chatJid, false);
```
In the `onOutput` callback inside `processGroupMessages`, replace:
```typescript
await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
```
with:
```typescript
const formatted = formatOutbound(channel, text);
if (formatted) await channel.sendMessage(chatJid, formatted);
```
4. **Update `main()` function** to create channels conditionally and use them for deps:
```typescript
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) =>
storeChatMetadata(chatJid, timestamp, name),
registeredGroups: () => registeredGroups,
};
// Create and connect channels
if (!TELEGRAM_ONLY) {
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
}
if (TELEGRAM_BOT_TOKEN) {
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
channels.push(telegram);
await telegram.connect();
}
// Start subsystems
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) return;
const text = formatOutbound(channel, rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
```
5. **Update `getAvailableGroups`** to include Telegram chats:
```typescript
export function getAvailableGroups(): AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((c) => c.jid !== '__group_sync__' && (c.jid.endsWith('@g.us') || c.jid.startsWith('tg:')))
.map((c) => ({
jid: c.jid,
name: c.name,
lastActivity: c.last_message_time,
isRegistered: registeredJids.has(c.jid),
}));
}
```
### Step 4: Update Environment
### Configure environment
Add to `.env`:
```bash
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Optional: Set to "true" to disable WhatsApp entirely
# TELEGRAM_ONLY=true
TELEGRAM_BOT_TOKEN=<their-token>
```
**Important**: After modifying `.env`, sync to the container environment:
If they chose to replace WhatsApp:
```bash
cp .env data/env/env
TELEGRAM_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.
### Step 5: Register a Telegram Chat
### Disable Group Privacy (for group chats)
After installing and starting the bot, tell the user:
Tell the user:
> 1. Send `/chatid` to your bot (in private chat or in a group)
> 2. Copy the chat ID (e.g., `tg:123456789` or `tg:-1001234567890`)
> 3. I'll register it for you
> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
>
> 1. Open Telegram and search for `@BotFather`
> 2. Send `/mybots` and select your bot
> 3. Go to **Bot Settings** > **Group Privacy** > **Turn off**
>
> This is optional if you only want trigger-based responses via @mentioning the bot.
Registration uses the `registerGroup()` function in `src/index.ts`, which writes to SQLite and creates the group folder structure. Call it like this (or add a one-time script):
```typescript
// For private chat (main group):
registerGroup("tg:123456789", {
name: "Personal",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false, // main group responds to all messages
});
// For group chat (note negative ID for Telegram groups):
registerGroup("tg:-1001234567890", {
name: "My Telegram Group",
folder: "telegram-group",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true, // only respond when triggered
});
```
The `RegisteredGroup` type requires a `trigger` string field and has an optional `requiresTrigger` boolean (defaults to `true`). Set `requiresTrigger: false` for chats that should respond to all messages.
Alternatively, if the agent is already running in the main group, it can register new groups via IPC using the `register_group` task type.
### Step 6: Build and Restart
### Build and restart
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
Or for systemd:
## Phase 4: Registration
```bash
npm run build
systemctl --user restart nanoclaw
### Get Chat ID
Tell the user:
> 1. Open your bot in Telegram (search for its username)
> 2. Send `/chatid` — it will reply with the chat ID
> 3. For groups: add the bot to the group first, then send `/chatid` in the group
Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`).
### Register the chat
Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
For a main chat (responds to all messages, uses the `main` folder):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
});
```
### Step 7: Test
For additional chats (trigger-only):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "<folder-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```
## Phase 5: Verify
### Test the connection
Tell the user:
@@ -566,91 +171,37 @@ Tell the user:
> - For main chat: Any message works
> - For non-main: `@Andy hello` or @mention the bot
>
> Check logs: `tail -f logs/nanoclaw.log`
> The bot should respond within a few seconds.
## Replace WhatsApp Entirely
### Check logs if needed
If user wants Telegram-only:
1. Set `TELEGRAM_ONLY=true` in `.env`
2. Run `cp .env data/env/env` to sync to container
3. The WhatsApp channel is not created — only Telegram
4. All services (scheduler, IPC watcher, queue, message loop) start normally
5. Optionally remove `@whiskeysockets/baileys` dependency (but it's harmless to keep)
## Features
### Chat ID Formats
- **WhatsApp**: `120363336345536173@g.us` (groups) or `1234567890@s.whatsapp.net` (DM)
- **Telegram**: `tg:123456789` (positive for private) or `tg:-1001234567890` (negative for groups)
### Trigger Options
The bot responds when:
1. Chat has `requiresTrigger: false` in its registration (e.g., main group)
2. Bot is @mentioned in Telegram (translated to TRIGGER_PATTERN automatically)
3. Message matches TRIGGER_PATTERN directly (e.g., starts with @Andy)
Telegram @mentions (e.g., `@andy_ai_bot`) are automatically translated: if the bot is @mentioned and the message doesn't already match TRIGGER_PATTERN, the trigger prefix is prepended before storing. This ensures @mentioning the bot always triggers a response.
**Group Privacy**: The bot must have Group Privacy disabled in BotFather to see non-mention messages in groups. See Prerequisites step 4.
### Commands
- `/chatid` - Get chat ID for registration
- `/ping` - Check if bot is online
```bash
tail -f logs/nanoclaw.log
```
## Troubleshooting
### Bot not responding
Check:
1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
3. For non-main chats: message includes trigger pattern
1. Check `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Check chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`
3. For non-main chats: message must include trigger pattern
4. Service is running: `launchctl list | grep nanoclaw`
### Bot only responds to @mentions in groups
The bot has Group Privacy enabled (default). It can only see messages that @mention it or are commands. To fix:
1. Open `@BotFather` in Telegram
2. `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off**
3. Remove and re-add the bot to the group (required for the change to take effect)
Group Privacy is enabled (default). Fix:
1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off**
2. Remove and re-add the bot to the group (required for the change to take effect)
### Getting chat ID
If `/chatid` doesn't work:
- Verify bot token is valid: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"`
- Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"`
- Check bot is started: `tail -f logs/nanoclaw.log`
### Service conflicts
## After Setup
If running `npm run dev` while launchd service is active:
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
```
Ask the user:
## Agent Swarms (Teams)
After completing the Telegram setup, ask the user:
> Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:
1. Delete `src/channels/telegram.ts`
2. Remove `TelegramChannel` import and creation from `src/index.ts`
3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
7. Uninstall: `npm uninstall grammy`
8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
> Would you like to add Agent Swarm support? Each subagent appears as a different bot in the Telegram group. If interested, run `/add-telegram-swarm`.

View File

@@ -0,0 +1,918 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// --- Grammy mock ---
type Handler = (...args: any[]) => any;
const botRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('grammy', () => ({
Bot: class MockBot {
token: string;
commandHandlers = new Map<string, Handler>();
filterHandlers = new Map<string, Handler[]>();
errorHandler: Handler | null = null;
api = {
sendMessage: vi.fn().mockResolvedValue(undefined),
sendChatAction: vi.fn().mockResolvedValue(undefined),
};
constructor(token: string) {
this.token = token;
botRef.current = this;
}
command(name: string, handler: Handler) {
this.commandHandlers.set(name, handler);
}
on(filter: string, handler: Handler) {
const existing = this.filterHandlers.get(filter) || [];
existing.push(handler);
this.filterHandlers.set(filter, existing);
}
catch(handler: Handler) {
this.errorHandler = handler;
}
start(opts: { onStart: (botInfo: any) => void }) {
opts.onStart({ username: 'andy_ai_bot', id: 12345 });
}
stop() {}
},
}));
import { TelegramChannel, TelegramChannelOpts } from './telegram.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<TelegramChannelOpts>,
): TelegramChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'tg:100200300': {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createTextCtx(overrides: {
chatId?: number;
chatType?: string;
chatTitle?: string;
text: string;
fromId?: number;
firstName?: string;
username?: string;
messageId?: number;
date?: number;
entities?: any[];
}) {
const chatId = overrides.chatId ?? 100200300;
const chatType = overrides.chatType ?? 'group';
return {
chat: {
id: chatId,
type: chatType,
title: overrides.chatTitle ?? 'Test Group',
},
from: {
id: overrides.fromId ?? 99001,
first_name: overrides.firstName ?? 'Alice',
username: overrides.username ?? 'alice_user',
},
message: {
text: overrides.text,
date: overrides.date ?? Math.floor(Date.now() / 1000),
message_id: overrides.messageId ?? 1,
entities: overrides.entities ?? [],
},
me: { username: 'andy_ai_bot' },
reply: vi.fn(),
};
}
function createMediaCtx(overrides: {
chatId?: number;
chatType?: string;
fromId?: number;
firstName?: string;
date?: number;
messageId?: number;
caption?: string;
extra?: Record<string, any>;
}) {
const chatId = overrides.chatId ?? 100200300;
return {
chat: {
id: chatId,
type: overrides.chatType ?? 'group',
title: 'Test Group',
},
from: {
id: overrides.fromId ?? 99001,
first_name: overrides.firstName ?? 'Alice',
username: 'alice_user',
},
message: {
date: overrides.date ?? Math.floor(Date.now() / 1000),
message_id: overrides.messageId ?? 1,
caption: overrides.caption,
...(overrides.extra || {}),
},
me: { username: 'andy_ai_bot' },
};
}
function currentBot() {
return botRef.current;
}
async function triggerTextMessage(ctx: ReturnType<typeof createTextCtx>) {
const handlers = currentBot().filterHandlers.get('message:text') || [];
for (const h of handlers) await h(ctx);
}
async function triggerMediaMessage(
filter: string,
ctx: ReturnType<typeof createMediaCtx>,
) {
const handlers = currentBot().filterHandlers.get(filter) || [];
for (const h of handlers) await h(ctx);
}
// --- Tests ---
describe('TelegramChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when bot starts', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers command and message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(currentBot().commandHandlers.has('chatid')).toBe(true);
expect(currentBot().commandHandlers.has('ping')).toBe(true);
expect(currentBot().filterHandlers.has('message:text')).toBe(true);
expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
expect(currentBot().filterHandlers.has('message:video')).toBe(true);
expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
expect(currentBot().filterHandlers.has('message:document')).toBe(true);
expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
expect(currentBot().filterHandlers.has('message:location')).toBe(true);
expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
});
it('registers error handler on connect', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(currentBot().errorHandler).not.toBeNull();
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Text message handling ---
describe('text message handling', () => {
it('delivers message for registered group', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hello everyone' });
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Test Group',
);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
id: '1',
chat_jid: 'tg:100200300',
sender: '99001',
sender_name: 'Alice',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:999999',
expect.any(String),
'Test Group',
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('skips command messages (starting with /)', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: '/start' });
await triggerTextMessage(ctx);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
});
it('extracts sender name from first_name', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: 'Bob' }),
);
});
it('falls back to username when first_name missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi' });
ctx.from.first_name = undefined as any;
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: 'alice_user' }),
);
});
it('falls back to user ID when name and username missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
ctx.from.first_name = undefined as any;
ctx.from.username = undefined as any;
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: '42' }),
);
});
it('uses sender name as chat name for private chats', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'tg:100200300': {
name: 'Private',
folder: 'private',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'Hello',
chatType: 'private',
firstName: 'Alice',
});
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Alice', // Private chats use sender name
);
});
it('uses chat title as name for group chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'Hello',
chatType: 'supergroup',
chatTitle: 'Project Team',
});
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Project Team',
);
});
it('converts message.date to ISO timestamp', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
const ctx = createTextCtx({ text: 'Hello', date: unixTime });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
timestamp: '2024-01-01T00:00:00.000Z',
}),
);
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('translates @bot_username mention to trigger format', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@andy_ai_bot what time is it?',
entities: [{ type: 'mention', offset: 0, length: 12 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy @andy_ai_bot what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@Andy @andy_ai_bot hello',
entities: [{ type: 'mention', offset: 6, length: 12 }],
});
await triggerTextMessage(ctx);
// Should NOT double-prepend — already starts with @Andy
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy @andy_ai_bot hello',
}),
);
});
it('does not translate mentions of other bots', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@some_other_bot hi',
entities: [{ type: 'mention', offset: 0, length: 15 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@some_other_bot hi', // No translation
}),
);
});
it('handles mention in middle of message', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'hey @andy_ai_bot check this',
entities: [{ type: 'mention', offset: 4, length: 12 }],
});
await triggerTextMessage(ctx);
// Bot is mentioned, message doesn't match trigger → prepend trigger
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy hey @andy_ai_bot check this',
}),
);
});
it('handles message with no entities', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'plain message' });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: 'plain message',
}),
);
});
it('ignores non-mention entities', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'check https://example.com',
entities: [{ type: 'url', offset: 6, length: 19 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: 'check https://example.com',
}),
);
});
});
// --- Non-text messages ---
describe('non-text messages', () => {
it('stores photo with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Photo]' }),
);
});
it('stores photo with caption', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ caption: 'Look at this' });
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Photo] Look at this' }),
);
});
it('stores video with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:video', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Video]' }),
);
});
it('stores voice message with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:voice', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Voice message]' }),
);
});
it('stores audio with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:audio', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Audio]' }),
);
});
it('stores document with filename', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({
extra: { document: { file_name: 'report.pdf' } },
});
await triggerMediaMessage('message:document', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Document: report.pdf]' }),
);
});
it('stores document with fallback name when filename missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ extra: { document: {} } });
await triggerMediaMessage('message:document', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Document: file]' }),
);
});
it('stores sticker with emoji', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({
extra: { sticker: { emoji: '😂' } },
});
await triggerMediaMessage('message:sticker', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Sticker 😂]' }),
);
});
it('stores location with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:location', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Location]' }),
);
});
it('stores contact with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:contact', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Contact]' }),
);
});
it('ignores non-text messages from unregistered chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ chatId: 999999 });
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).not.toHaveBeenCalled();
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via bot API', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('tg:100200300', 'Hello');
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
'100200300',
'Hello',
);
});
it('strips tg: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('tg:-1001234567890', 'Group message');
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
'Group message',
);
});
it('splits messages exceeding 4096 characters', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const longText = 'x'.repeat(5000);
await channel.sendMessage('tg:100200300', longText);
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
1,
'100200300',
'x'.repeat(4096),
);
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
2,
'100200300',
'x'.repeat(904),
);
});
it('sends exactly one message at 4096 characters', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const exactText = 'y'.repeat(4096);
await channel.sendMessage('tg:100200300', exactText);
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
});
it('handles send failure gracefully', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
currentBot().api.sendMessage.mockRejectedValueOnce(
new Error('Network error'),
);
// Should not throw
await expect(
channel.sendMessage('tg:100200300', 'Will fail'),
).resolves.toBeUndefined();
});
it('does nothing when bot is not initialized', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
// Don't connect — bot is null
await channel.sendMessage('tg:100200300', 'No bot');
// No error, no API call
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns tg: JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:123456')).toBe(true);
});
it('owns tg: JIDs with negative IDs (groups)', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own WhatsApp DM JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- setTyping ---
describe('setTyping', () => {
it('sends typing action when isTyping is true', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.setTyping('tg:100200300', true);
expect(currentBot().api.sendChatAction).toHaveBeenCalledWith(
'100200300',
'typing',
);
});
it('does nothing when isTyping is false', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.setTyping('tg:100200300', false);
expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
});
it('does nothing when bot is not initialized', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
// Don't connect
await channel.setTyping('tg:100200300', true);
// No error, no API call
});
it('handles typing indicator failure gracefully', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
currentBot().api.sendChatAction.mockRejectedValueOnce(
new Error('Rate limited'),
);
await expect(
channel.setTyping('tg:100200300', true),
).resolves.toBeUndefined();
});
});
// --- Bot commands ---
describe('bot commands', () => {
it('/chatid replies with chat ID and metadata', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('chatid')!;
const ctx = {
chat: { id: 100200300, type: 'group' as const },
from: { first_name: 'Alice' },
reply: vi.fn(),
};
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith(
expect.stringContaining('tg:100200300'),
expect.objectContaining({ parse_mode: 'Markdown' }),
);
});
it('/chatid shows chat type', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('chatid')!;
const ctx = {
chat: { id: 555, type: 'private' as const },
from: { first_name: 'Bob' },
reply: vi.fn(),
};
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith(
expect.stringContaining('private'),
expect.any(Object),
);
});
it('/ping replies with bot status', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('ping')!;
const ctx = { reply: vi.fn() };
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "telegram"', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.name).toBe('telegram');
});
});
});

View File

@@ -0,0 +1,242 @@
import { Bot } from 'grammy';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { logger } from '../logger.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class TelegramChannel implements Channel {
name = 'telegram';
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
// Command to get chat ID (useful for registration)
this.bot.command('chatid', (ctx) => {
const chatId = ctx.chat.id;
const chatType = ctx.chat.type;
const chatName =
chatType === 'private'
? ctx.from?.first_name || 'Private'
: (ctx.chat as any).title || 'Unknown';
ctx.reply(
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
{ parse_mode: 'Markdown' },
);
});
// Command to check bot status
this.bot.command('ping', (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
this.bot.on('message:text', async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith('/')) return;
const chatJid = `tg:${ctx.chat.id}`;
let content = ctx.message.text;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id.toString() ||
'Unknown';
const sender = ctx.from?.id.toString() || '';
const msgId = ctx.message.message_id.toString();
// Determine chat name
const chatName =
ctx.chat.type === 'private'
? senderName
: (ctx.chat as any).title || chatJid;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
const botUsername = ctx.me?.username?.toLowerCase();
if (botUsername) {
const entities = ctx.message.entities || [];
const isBotMentioned = entities.some((entity) => {
if (entity.type === 'mention') {
const mentionText = content
.substring(entity.offset, entity.offset + entity.length)
.toLowerCase();
return mentionText === `@${botUsername}`;
}
return false;
});
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
// Store chat metadata for discovery
this.opts.onChatMetadata(chatJid, timestamp, chatName);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Telegram chat',
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Telegram message stored',
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
const storeNonText = (ctx: any, placeholder: string) => {
const chatJid = `tg:${ctx.chat.id}`;
const group = this.opts.registeredGroups()[chatJid];
if (!group) return;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id?.toString() ||
'Unknown';
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
this.opts.onChatMetadata(chatJid, timestamp);
this.opts.onMessage(chatJid, {
id: ctx.message.message_id.toString(),
chat_jid: chatJid,
sender: ctx.from?.id?.toString() || '',
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
};
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
this.bot.on('message:voice', (ctx) =>
storeNonText(ctx, '[Voice message]'),
);
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
this.bot.on('message:document', (ctx) => {
const name = ctx.message.document?.file_name || 'file';
storeNonText(ctx, `[Document: ${name}]`);
});
this.bot.on('message:sticker', (ctx) => {
const emoji = ctx.message.sticker?.emoji || '';
storeNonText(ctx, `[Sticker ${emoji}]`);
});
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
// Handle errors gracefully
this.bot.catch((err) => {
logger.error({ err: err.message }, 'Telegram bot error');
});
// Start polling — returns a Promise that resolves when started
return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => {
logger.info(
{ username: botInfo.username, id: botInfo.id },
'Telegram bot connected',
);
console.log(`\n Telegram bot: @${botInfo.username}`);
console.log(
` Send /chatid to the bot to get a chat's registration ID\n`,
);
resolve();
},
});
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.bot) {
logger.warn('Telegram bot not initialized');
return;
}
try {
const numericId = jid.replace(/^tg:/, '');
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await this.bot.api.sendMessage(
numericId,
text.slice(i, i + MAX_LENGTH),
);
}
}
logger.info({ jid, length: text.length }, 'Telegram message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Telegram message');
}
}
isConnected(): boolean {
return this.bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith('tg:');
}
async disconnect(): Promise<void> {
if (this.bot) {
this.bot.stop();
this.bot = null;
logger.info('Telegram bot stopped');
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.bot || !isTyping) return;
try {
const numericId = jid.replace(/^tg:/, '');
await this.bot.api.sendChatAction(numericId, 'typing');
} catch (err) {
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
}
}
}

View File

@@ -0,0 +1,20 @@
skill: telegram
version: 1.0.0
description: "Telegram Bot API integration via Grammy"
core_version: 0.1.0
adds:
- src/channels/telegram.ts
- src/channels/telegram.test.ts
modifies:
- src/index.ts
- src/config.ts
- src/routing.test.ts
structured:
npm_dependencies:
grammy: "^1.39.3"
env_additions:
- TELEGRAM_BOT_TOKEN
- TELEGRAM_ONLY
conflicts: []
depends: []
test: "npx vitest run src/channels/telegram.test.ts"

View File

@@ -0,0 +1,76 @@
import path from 'path';
import { readEnvFile } from './env.js';
// Read config values from .env (falls back to process.env).
// Secrets are NOT read here — they stay on disk and are loaded only
// where needed (container-runner.ts) to avoid leaking to child processes.
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_ONLY',
]);
export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
// Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || '/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;
// Telegram configuration
export const TELEGRAM_BOT_TOKEN =
process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
export const TELEGRAM_ONLY =
(process.env.TELEGRAM_ONLY || envConfig.TELEGRAM_ONLY) === 'true';

View File

@@ -0,0 +1,21 @@
# Intent: src/config.ts modifications
## What changed
Added two new configuration exports for Telegram channel support.
## Key sections
- **readEnvFile call**: Must include `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ONLY` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
- **TELEGRAM_BOT_TOKEN**: Read from `process.env` first, then `envConfig` fallback, defaults to empty string (channel disabled when empty)
- **TELEGRAM_ONLY**: Boolean flag from `process.env` or `envConfig`, when `true` disables WhatsApp channel creation
## Invariants
- All existing config exports remain unchanged
- New Telegram keys are added to the `readEnvFile` call alongside existing keys
- New exports are appended at the end of the file
- No existing behavior is modified — Telegram config is additive only
- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
## Must-keep
- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
- The `escapeRegex` helper and `TRIGGER_PATTERN` construction

View File

@@ -0,0 +1,537 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
DATA_DIR,
IDLE_TIMEOUT,
MAIN_GROUP_FOLDER,
POLL_INTERVAL,
TELEGRAM_BOT_TOKEN,
TELEGRAM_ONLY,
TRIGGER_PATTERN,
} from './config.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import { TelegramChannel } from './channels/telegram.js';
import {
ContainerOutput,
runContainerAgent,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.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';
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
let messageLoopRunning = false;
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
try {
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
} catch {
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups();
logger.info(
{ groupCount: Object.keys(registeredGroups).length },
'State loaded',
);
}
function saveState(): void {
setRouterState('last_timestamp', lastTimestamp);
setRouterState(
'last_agent_timestamp',
JSON.stringify(lastAgentTimestamp),
);
}
function registerGroup(jid: string, group: RegisteredGroup): void {
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) 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) 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 {
try {
execSync('container system status', { stdio: 'pipe' });
logger.debug('Apple Container system already running');
} catch {
logger.info('Starting Apple Container system...');
try {
execSync('container system start', { stdio: 'pipe', timeout: 30000 });
logger.info('Apple Container system started');
} catch (err) {
logger.error({ err }, 'Failed to start Apple Container system');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Apple Container system failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without Apple Container. To fix: ║',
);
console.error(
'║ 1. Install from: https://github.com/apple/container/releases ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Apple Container system is required but failed to start');
}
}
// Kill and clean up orphaned NanoClaw containers from previous runs
try {
const output = execSync('container ls --format json', {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
execSync(`container stop ${name}`, { stdio: 'pipe' });
} catch { /* already stopped */ }
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
// Create and connect channels
if (!TELEGRAM_ONLY) {
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
}
if (TELEGRAM_BOT_TOKEN) {
const telegram = new TelegramChannel(TELEGRAM_BOT_TOKEN, channelOpts);
channels.push(telegram);
await telegram.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) return;
const text = formatOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
// 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,50 @@
# Intent: src/index.ts modifications
## What changed
Refactored from single WhatsApp channel to multi-channel architecture using the `Channel` interface.
## Key sections
### Imports (top of file)
- Added: `TelegramChannel` from `./channels/telegram.js`
- Added: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY` from `./config.js`
- Added: `findChannel` from `./router.js`
- Added: `Channel` type from `./types.js`
### Module-level state
- Added: `const channels: Channel[] = []` — array of all active channels
- Kept: `let whatsapp: WhatsAppChannel` — still needed for `syncGroupMetadata` reference
### processGroupMessages()
- Added: `findChannel(channels, chatJid)` lookup at the start
- Changed: `whatsapp.setTyping()``channel.setTyping?.()` (optional chaining)
- Changed: `whatsapp.sendMessage()``channel.sendMessage()` in output callback
### getAvailableGroups()
- Unchanged: uses `c.is_group` filter from base (Telegram channels pass `isGroup=true` via `onChatMetadata`)
### startMessageLoop()
- Added: `findChannel(channels, chatJid)` lookup per group in message processing
- Changed: `whatsapp.setTyping()``channel.setTyping?.()` for typing indicators
### main()
- Changed: shutdown disconnects all channels via `for (const ch of channels)`
- Added: shared `channelOpts` object for channel callbacks
- Added: conditional WhatsApp creation (`if (!TELEGRAM_ONLY)`)
- Added: conditional Telegram creation (`if (TELEGRAM_BOT_TOKEN)`)
- Changed: scheduler `sendMessage` uses `findChannel()``channel.sendMessage()`
- Changed: IPC `sendMessage` uses `findChannel()``channel.sendMessage()`
## Invariants
- All existing message processing logic (triggers, cursors, idle timers) is preserved
- The `runAgent` function is completely unchanged
- State management (loadState/saveState) is unchanged
- Recovery logic is unchanged
- Apple Container check is unchanged (ensureContainerSystemRunning)
## Must-keep
- The `escapeXml` and `formatMessages` re-exports
- The `_setRegisteredGroups` test helper
- The `isDirectRun` guard at bottom
- All error handling and cursor rollback logic in processGroupMessages
- The outgoing queue flush and reconnection logic (in WhatsAppChannel, not here)

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('Telegram JID: starts with tg:', () => {
const jid = 'tg:123456789';
expect(jid.startsWith('tg:')).toBe(true);
});
it('Telegram group JID: starts with tg: and has negative ID', () => {
const jid = 'tg:-1001234567890';
expect(jid.startsWith('tg:')).toBe(true);
});
});
// --- getAvailableGroups ---
describe('getAvailableGroups', () => {
it('returns only groups, excludes DMs', () => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.jid)).toContain('group1@g.us');
expect(groups.map((g) => g.jid)).toContain('group2@g.us');
expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
});
it('excludes __group_sync__ sentinel', () => {
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('marks registered groups correctly', () => {
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
_setRegisteredGroups({
'reg@g.us': {
name: 'Registered',
folder: 'registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const reg = groups.find((g) => g.jid === 'reg@g.us');
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
expect(reg?.isRegistered).toBe(true);
expect(unreg?.isRegistered).toBe(false);
});
it('returns groups ordered by most recent activity', () => {
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups[0].jid).toBe('new@g.us');
expect(groups[1].jid).toBe('mid@g.us');
expect(groups[2].jid).toBe('old@g.us');
});
it('excludes non-group chats regardless of JID format', () => {
// Unknown JID format stored without is_group should not appear
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
// Explicitly non-group with unusual JID
storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
// A real group for contrast
storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('returns empty array when no chats exist', () => {
const groups = getAvailableGroups();
expect(groups).toHaveLength(0);
});
it('includes Telegram chat JIDs', () => {
storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'Telegram Chat', 'telegram', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('tg:100200300');
});
it('returns Telegram group JIDs with negative IDs', () => {
storeChatMetadata('tg:-1001234567890', '2024-01-01T00:00:01.000Z', 'TG Group', 'telegram', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('tg:-1001234567890');
expect(groups[0].name).toBe('TG Group');
});
it('marks registered Telegram chats correctly', () => {
storeChatMetadata('tg:100200300', '2024-01-01T00:00:01.000Z', 'TG Registered', 'telegram', true);
storeChatMetadata('tg:999999', '2024-01-01T00:00:02.000Z', 'TG Unregistered', 'telegram', true);
_setRegisteredGroups({
'tg:100200300': {
name: 'TG Registered',
folder: 'tg-registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const tgReg = groups.find((g) => g.jid === 'tg:100200300');
const tgUnreg = groups.find((g) => g.jid === 'tg:999999');
expect(tgReg?.isRegistered).toBe(true);
expect(tgUnreg?.isRegistered).toBe(false);
});
it('mixes WhatsApp and Telegram chats ordered by activity', () => {
storeChatMetadata('wa@g.us', '2024-01-01T00:00:01.000Z', 'WhatsApp', 'whatsapp', true);
storeChatMetadata('tg:100', '2024-01-01T00:00:03.000Z', 'Telegram', 'telegram', true);
storeChatMetadata('wa2@g.us', '2024-01-01T00:00:02.000Z', 'WhatsApp 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(3);
expect(groups[0].jid).toBe('tg:100');
expect(groups[1].jid).toBe('wa2@g.us');
expect(groups[2].jid).toBe('wa@g.us');
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('telegram skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: telegram');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('grammy');
});
it('has all files declared in adds', () => {
const addFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.ts');
expect(fs.existsSync(addFile)).toBe(true);
const content = fs.readFileSync(addFile, 'utf-8');
expect(content).toContain('class TelegramChannel');
expect(content).toContain('implements Channel');
// Test file for the channel
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'telegram.test.ts');
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('TelegramChannel'");
});
it('has all files declared in modifies', () => {
const indexFile = path.join(skillDir, 'modify', 'src', 'index.ts');
const configFile = path.join(skillDir, 'modify', 'src', 'config.ts');
const routingTestFile = path.join(skillDir, 'modify', 'src', 'routing.test.ts');
expect(fs.existsSync(indexFile)).toBe(true);
expect(fs.existsSync(configFile)).toBe(true);
expect(fs.existsSync(routingTestFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain('TelegramChannel');
expect(indexContent).toContain('TELEGRAM_BOT_TOKEN');
expect(indexContent).toContain('TELEGRAM_ONLY');
expect(indexContent).toContain('findChannel');
expect(indexContent).toContain('channels: Channel[]');
const configContent = fs.readFileSync(configFile, 'utf-8');
expect(configContent).toContain('TELEGRAM_BOT_TOKEN');
expect(configContent).toContain('TELEGRAM_ONLY');
});
it('has intent files for modified files', () => {
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'index.ts.intent.md'))).toBe(true);
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'config.ts.intent.md'))).toBe(true);
});
it('modified index.ts preserves core structure', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'index.ts'),
'utf-8',
);
// Core functions still present
expect(content).toContain('function loadState()');
expect(content).toContain('function saveState()');
expect(content).toContain('function registerGroup(');
expect(content).toContain('function getAvailableGroups()');
expect(content).toContain('function processGroupMessages(');
expect(content).toContain('function runAgent(');
expect(content).toContain('function startMessageLoop()');
expect(content).toContain('function recoverPendingMessages()');
expect(content).toContain('function ensureContainerSystemRunning()');
expect(content).toContain('async function main()');
// Test helper preserved
expect(content).toContain('_setRegisteredGroups');
// Direct-run guard preserved
expect(content).toContain('isDirectRun');
});
it('modified index.ts includes Telegram channel creation', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'index.ts'),
'utf-8',
);
// Multi-channel architecture
expect(content).toContain('const channels: Channel[] = []');
expect(content).toContain('channels.push(whatsapp)');
expect(content).toContain('channels.push(telegram)');
// Conditional channel creation
expect(content).toContain('if (!TELEGRAM_ONLY)');
expect(content).toContain('if (TELEGRAM_BOT_TOKEN)');
// Shutdown disconnects all channels
expect(content).toContain('for (const ch of channels) await ch.disconnect()');
});
it('modified config.ts preserves all existing exports', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'config.ts'),
'utf-8',
);
// All original exports preserved
expect(content).toContain('export const ASSISTANT_NAME');
expect(content).toContain('export const POLL_INTERVAL');
expect(content).toContain('export const TRIGGER_PATTERN');
expect(content).toContain('export const CONTAINER_IMAGE');
expect(content).toContain('export const DATA_DIR');
expect(content).toContain('export const TIMEZONE');
});
});