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:
918
.claude/skills/add-telegram/add/src/channels/telegram.test.ts
Normal file
918
.claude/skills/add-telegram/add/src/channels/telegram.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
242
.claude/skills/add-telegram/add/src/channels/telegram.ts
Normal file
242
.claude/skills/add-telegram/add/src/channels/telegram.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user