* refactor: implement channel architecture and dynamic setup - Introduced ChannelRegistry for dynamic channel loading - Decoupled WhatsApp from core index.ts and config.ts - Updated setup wizard to support ENABLED_CHANNELS selection - Refactored IPC and group registration to be channel-aware - Verified with 359 passing tests and clean typecheck * style: fix formatting in config.ts to pass CI * refactor(setup): full platform-agnostic transformation - Harmonized all instructional text and help prompts - Implemented conditional guards for WhatsApp-specific steps - Normalized CLI terminology across all 4 initial channels - Unified troubleshooting and verification logic - Verified 369 tests pass with clean typecheck * feat(skills): transform WhatsApp into a pluggable skill - Created .claude/skills/add-whatsapp with full 5-phase interactive setup - Fixed TS7006 'implicit any' error in IpcDeps - Added auto-creation of STORE_DIR to prevent crashes on fresh installs - Verified with 369 passing tests and clean typecheck * refactor(skills): move WhatsApp from core to pluggable skill - Move src/channels/whatsapp.ts to add-whatsapp skill add/ folder - Move src/channels/whatsapp.test.ts to skill add/ folder - Move src/whatsapp-auth.ts to skill add/ folder - Create modify/ for barrel file (src/channels/index.ts) - Create tests/ with skill package validation test - Update manifest with adds/modifies lists - Remove WhatsApp deps from core package.json (now skill-managed) - Remove WhatsApp-specific ghost language from types.ts - Update SKILL.md to reflect skill-apply workflow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(skills): move setup/whatsapp-auth.ts into WhatsApp skill The WhatsApp auth setup step is channel-specific — move it from core to the add-whatsapp skill so core stays minimal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(skills): convert Telegram skill to pluggable channel pattern Replace the old direct-integration approach (modifying src/index.ts, src/config.ts, src/routing.test.ts) with self-registration via the channel registry, matching the WhatsApp skill pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(skills): fix add-whatsapp build failure and improve auth flow - Add missing @types/qrcode-terminal to manifest npm_dependencies (build failed after skill apply without it) - Make QR-browser the recommended auth method (terminal QR too small, pairing codes expire too fast) - Remove "replace vs alongside" question — channels are additive - Add pairing code retry guidance and QR-browser fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove hardcoded WhatsApp default and stale Baileys comment - ENABLED_CHANNELS now defaults to empty (fresh installs must configure channels explicitly via /setup; existing installs already have .env) - Remove Baileys-specific comment from storeMessageDirect() in db.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(skills): convert Discord, Slack, Gmail skills to pluggable channel pattern All channel skills now use the same self-registration pattern: - registerChannel() factory at module load time - Barrel file append (src/channels/index.ts) instead of orchestrator modifications - No more *_ONLY flags (DISCORD_ONLY, SLACK_ONLY) — use ENABLED_CHANNELS instead - Removed ~2500 lines of old modify/ files (src/index.ts, src/config.ts, src/routing.test.ts) Gmail retains its container-runner.ts and agent-runner modifications (MCP mount + server config) since those are independent of channel wiring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use getRegisteredChannels instead of ENABLED_CHANNELS Remove the ENABLED_CHANNELS env var entirely. The orchestrator now iterates getRegisteredChannelNames() from the channel registry — channels self-register via barrel imports and their factories return null when credentials are missing, so unconfigured channels are skipped automatically. Deleted setup/channels.ts (and its tests) since its sole purpose was writing ENABLED_CHANNELS to .env. Refactored verify, groups, and environment setup steps to detect channels by credential presence instead of reading ENABLED_CHANNELS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add breaking change notice and whatsapp migration instructions CHANGELOG.md documents the pluggable channel architecture shift and provides migration steps for existing WhatsApp users. CLAUDE.md updated: Quick Context reflects multi-channel architecture, Key Files lists registry.ts instead of whatsapp.ts, and a new Troubleshooting section directs users to /add-whatsapp if WhatsApp stops connecting after upgrade. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: rewrite READMEs for pluggable multi-channel architecture Reflects the architectural shift from a hardcoded WhatsApp bot to a pluggable channel platform. Adds upgrading notice, Mermaid architecture diagram, CI/License/TypeScript/PRs badges, and clarifies that slash commands run inside the Claude Code CLI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: move pluggable channel architecture details to SPEC.md Revert READMEs to original tone with only two targeted changes: - Add upgrading notice for WhatsApp breaking change - Mention pluggable channels in "What It Supports" Move Mermaid diagram, channel registry internals, factory pattern explanation, and self-registration walkthrough into docs/SPEC.md. Update stale WhatsApp-specific references in SPEC.md to be channel-agnostic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: move upgrading notice to CHANGELOG, add changelog link Remove the "Upgrading from Pre-Pluggable Versions" section from README.md — breaking change details belong in the CHANGELOG. Add a Changelog section linking to CHANGELOG.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: expand CHANGELOG with full PR #500 changes Cover all changes: channel registry, WhatsApp moved to skill, removed core dependencies, all 5 skills simplified, orchestrator refactored, setup decoupled. Use Claude Code CLI instructions for migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to 1.2.0 for pluggable channel architecture Minor version bump — new functionality (pluggable channels) with a managed migration path for existing WhatsApp users. Update version references in CHANGELOG and update skill. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix skill application * fix: use slotted barrel file to prevent channel merge conflicts Pre-allocate a named comment slot for each channel in src/channels/index.ts, separated by blank lines. Each skill's modify file only touches its own slot, so three-way merges never conflict when applying multiple channels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve real chat ID during setup for token-based channels Instead of registering with `pending@telegram` (which never matches incoming messages), the setup skill now runs an inline bot that waits for the user to send /chatid, capturing the real chat ID before registration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: setup delegates to channel skills, fix group sync and Discord metadata - Restructure setup SKILL.md to delegate channel setup to individual channel skills (/add-whatsapp, /add-telegram, etc.) instead of reimplementing auth/registration inline with broken placeholder JIDs - Move channel selection to step 5 where it's immediately acted on - Fix setup/groups.ts: write sync script to temp file instead of passing via node -e which broke on shell escaping of newlines - Fix Discord onChatMetadata missing channel and isGroup parameters - Add .tmp-* to .gitignore for temp sync script cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align add-whatsapp skill with main setup patterns Add headless detection for auth method selection, structured inline error handling, dedicated number DM flow, and reorder questions to match main's trigger-first flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add missing auth script to package.json The add-whatsapp skill adds src/whatsapp-auth.ts but doesn't add the corresponding npm script. Setup and SKILL.md reference `npm run auth` for WhatsApp QR terminal authentication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update Discord skill tests to match onChatMetadata signature The onChatMetadata callback now takes 5 arguments (jid, timestamp, name, channel, isGroup) but the Discord skill tests only expected 3. This caused skill application to roll back on test failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: replace 'pluggable' jargon with clearer language User-facing text now says "multi-channel" or describes what it does. Developer-facing text uses "self-registering" or "channel registry". Also removes extra badge row from README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: align Chinese README with English version Remove extra badges, replace pluggable jargon, remove upgrade section (now in CHANGELOG), add missing intro line and changelog section, fix setup FAQ answer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: warn on installed-but-unconfigured channels instead of silent skip Channels with missing credentials now emit WARN logs naming the exact missing variable, so misconfigurations surface instead of being hidden. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: simplify changelog to one-liner with compare link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add isMain flag and channel-prefixed group folders Replace MAIN_GROUP_FOLDER constant with explicit isMain boolean on RegisteredGroup. Group folders now use channel prefix convention (e.g., whatsapp_main, telegram_family-chat) to prevent cross-channel collisions. - Add isMain to RegisteredGroup type and SQLite schema (with migration) - Replace all folder-based main group checks with group.isMain - Add --is-main flag to setup/register.ts - Strip isMain from IPC payload (defense in depth) - Update MCP tool description for channel-prefixed naming - Update all channel SKILL.md files and documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gavrielc <gabicohen22@yahoo.com> Co-authored-by: Koshkoshinski <daniel.milliner@gmail.com>
933 lines
27 KiB
TypeScript
933 lines
27 KiB
TypeScript
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<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',
|
|
'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 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
|
|
'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',
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|