Files
Regolith/.claude/skills/add-telegram/add/src/channels/telegram.test.ts
gavrielc 51788de3b9 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>
2026-02-19 01:55:00 +02:00

919 lines
26 KiB
TypeScript

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