From 675a6d87a322c318cd476fd47f7a0bca05dca8d1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 13:25:58 +0200 Subject: [PATCH] chore: remove accidentally merged Telegram channel code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/index.ts | 1 - src/channels/telegram.test.ts | 949 ---------------------------------- src/channels/telegram.ts | 304 ----------- 3 files changed, 1254 deletions(-) delete mode 100644 src/channels/telegram.test.ts delete mode 100644 src/channels/telegram.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index 48356db..44f4f55 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -8,6 +8,5 @@ // slack // telegram -import './telegram.js'; // whatsapp diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts deleted file mode 100644 index 538c87b..0000000 --- a/src/channels/telegram.test.ts +++ /dev/null @@ -1,949 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -// Mock env reader (used by the factory, not needed in unit tests) -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); - -// Mock config -vi.mock('../config.js', () => ({ - ASSISTANT_NAME: 'Andy', - TRIGGER_PATTERN: /^@Andy\b/i, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// --- 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(); - filterHandlers = new Map(); - 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 { - 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; -}) { - 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) { - const handlers = currentBot().filterHandlers.get('message:text') || []; - for (const h of handlers) await h(ctx); -} - -async function triggerMediaMessage( - filter: string, - ctx: ReturnType, -) { - 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', - 'telegram', - true, - ); - 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', - 'telegram', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('skips bot commands (/chatid, /ping) but passes other / messages through', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - // Bot commands should be skipped - const ctx1 = createTextCtx({ text: '/chatid' }); - await triggerTextMessage(ctx1); - expect(opts.onMessage).not.toHaveBeenCalled(); - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - - const ctx2 = createTextCtx({ text: '/ping' }); - await triggerTextMessage(ctx2); - expect(opts.onMessage).not.toHaveBeenCalled(); - - // Non-bot /commands should flow through - const ctx3 = createTextCtx({ text: '/remote-control' }); - await triggerTextMessage(ctx3); - expect(opts.onMessage).toHaveBeenCalledTimes(1); - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '/remote-control' }), - ); - }); - - 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 - 'telegram', - false, - ); - }); - - 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', - 'telegram', - true, - ); - }); - - 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', - { parse_mode: 'Markdown' }, - ); - }); - - 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', - { parse_mode: 'Markdown' }, - ); - }); - - 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), - { parse_mode: 'Markdown' }, - ); - expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( - 2, - '100200300', - 'x'.repeat(904), - { parse_mode: 'Markdown' }, - ); - }); - - 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'); - }); - }); -}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts deleted file mode 100644 index effca6e..0000000 --- a/src/channels/telegram.ts +++ /dev/null @@ -1,304 +0,0 @@ -import https from 'https'; -import { Api, Bot } from 'grammy'; - -import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; -import { readEnvFile } from '../env.js'; -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnChatMetadata, - OnInboundMessage, - RegisteredGroup, -} from '../types.js'; - -export interface TelegramChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -/** - * Send a message with Telegram Markdown parse mode, falling back to plain text. - * Claude's output naturally matches Telegram's Markdown v1 format: - * *bold*, _italic_, `code`, ```code blocks```, [links](url) - */ -async function sendTelegramMessage( - api: { sendMessage: Api['sendMessage'] }, - chatId: string | number, - text: string, - options: { message_thread_id?: number } = {}, -): Promise { - try { - await api.sendMessage(chatId, text, { - ...options, - parse_mode: 'Markdown', - }); - } catch (err) { - // Fallback: send as plain text if Markdown parsing fails - logger.debug({ err }, 'Markdown send failed, falling back to plain text'); - await api.sendMessage(chatId, text, options); - } -} - -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 { - this.bot = new Bot(this.botToken, { - client: { - baseFetchConfig: { agent: https.globalAgent, compress: true }, - }, - }); - - // 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.`); - }); - - // Telegram bot commands handled above — skip them in the general handler - // so they don't also get stored as messages. All other /commands flow through. - const TELEGRAM_BOT_COMMANDS = new Set(['chatid', 'ping']); - - this.bot.on('message:text', async (ctx) => { - if (ctx.message.text.startsWith('/')) { - const cmd = ctx.message.text.slice(1).split(/[\s@]/)[0].toLowerCase(); - if (TELEGRAM_BOT_COMMANDS.has(cmd)) 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 - const isGroup = - ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata( - chatJid, - timestamp, - chatName, - 'telegram', - isGroup, - ); - - // 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}` : ''; - - const isGroup = - ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'telegram', - isGroup, - ); - 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((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 { - 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 sendTelegramMessage(this.bot.api, numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await sendTelegramMessage( - this.bot.api, - 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 { - if (this.bot) { - this.bot.stop(); - this.bot = null; - logger.info('Telegram bot stopped'); - } - } - - async setTyping(jid: string, isTyping: boolean): Promise { - 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'); - } - } -} - -registerChannel('telegram', (opts: ChannelOpts) => { - const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']); - const token = - process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || ''; - if (!token) { - logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set'); - return null; - } - return new TelegramChannel(token, opts); -});