* feat: add Telegram channel with agent swarm support Add Telegram as a messaging channel that can run alongside WhatsApp or standalone (TELEGRAM_ONLY mode). Includes bot pool support for agent swarms where each subagent appears as a different bot identity in the group. - Add grammy dependency for Telegram Bot API - Route messages through tg: JID prefix convention - Add storeMessageDirect for non-Baileys channels - Add sender field to IPC send_message for swarm identity - Support TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY, TELEGRAM_BOT_POOL config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add index.ts refactor plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract channel abstraction, IPC, and router from index.ts Break the 1088-line monolith into focused modules: - src/channels/whatsapp.ts: WhatsAppChannel class implementing Channel interface - src/ipc.ts: IPC watcher and task processing with dependency injection - src/router.ts: message formatting, outbound routing, channel lookup - src/types.ts: Channel interface, OnInboundMessage, OnChatMetadata types Also adds regression test suite (98 tests), updates all documentation and skill files to reflect the new architecture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add test workflow for PRs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove accidentally committed pool-bot assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): remove grammy from base dependencies Grammy is installed by the /add-telegram skill, not a base dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
7.5 KiB
TypeScript
247 lines
7.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
|
|
import {
|
|
escapeXml,
|
|
formatMessages,
|
|
formatOutbound,
|
|
stripInternalTags,
|
|
} from './router.js';
|
|
import { Channel, NewMessage } from './types.js';
|
|
|
|
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
|
return {
|
|
id: '1',
|
|
chat_jid: 'group@g.us',
|
|
sender: '123@s.whatsapp.net',
|
|
sender_name: 'Alice',
|
|
content: 'hello',
|
|
timestamp: '2024-01-01T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// --- escapeXml ---
|
|
|
|
describe('escapeXml', () => {
|
|
it('escapes ampersands', () => {
|
|
expect(escapeXml('a & b')).toBe('a & b');
|
|
});
|
|
|
|
it('escapes less-than', () => {
|
|
expect(escapeXml('a < b')).toBe('a < b');
|
|
});
|
|
|
|
it('escapes greater-than', () => {
|
|
expect(escapeXml('a > b')).toBe('a > b');
|
|
});
|
|
|
|
it('escapes double quotes', () => {
|
|
expect(escapeXml('"hello"')).toBe('"hello"');
|
|
});
|
|
|
|
it('handles multiple special characters together', () => {
|
|
expect(escapeXml('a & b < c > d "e"')).toBe(
|
|
'a & b < c > d "e"',
|
|
);
|
|
});
|
|
|
|
it('passes through strings with no special chars', () => {
|
|
expect(escapeXml('hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
expect(escapeXml('')).toBe('');
|
|
});
|
|
});
|
|
|
|
// --- formatMessages ---
|
|
|
|
describe('formatMessages', () => {
|
|
it('formats a single message as XML', () => {
|
|
const result = formatMessages([makeMsg()]);
|
|
expect(result).toBe(
|
|
'<messages>\n' +
|
|
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' +
|
|
'</messages>',
|
|
);
|
|
});
|
|
|
|
it('formats multiple messages', () => {
|
|
const msgs = [
|
|
makeMsg({ id: '1', sender_name: 'Alice', content: 'hi', timestamp: 't1' }),
|
|
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
|
|
];
|
|
const result = formatMessages(msgs);
|
|
expect(result).toContain('sender="Alice"');
|
|
expect(result).toContain('sender="Bob"');
|
|
expect(result).toContain('>hi</message>');
|
|
expect(result).toContain('>hey</message>');
|
|
});
|
|
|
|
it('escapes special characters in sender names', () => {
|
|
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]);
|
|
expect(result).toContain('sender="A & B <Co>"');
|
|
});
|
|
|
|
it('escapes special characters in content', () => {
|
|
const result = formatMessages([
|
|
makeMsg({ content: '<script>alert("xss")</script>' }),
|
|
]);
|
|
expect(result).toContain(
|
|
'<script>alert("xss")</script>',
|
|
);
|
|
});
|
|
|
|
it('handles empty array', () => {
|
|
const result = formatMessages([]);
|
|
expect(result).toBe('<messages>\n\n</messages>');
|
|
});
|
|
});
|
|
|
|
// --- TRIGGER_PATTERN ---
|
|
|
|
describe('TRIGGER_PATTERN', () => {
|
|
it('matches @Andy at start of message', () => {
|
|
expect(TRIGGER_PATTERN.test('@Andy hello')).toBe(true);
|
|
});
|
|
|
|
it('matches case-insensitively', () => {
|
|
expect(TRIGGER_PATTERN.test('@andy hello')).toBe(true);
|
|
expect(TRIGGER_PATTERN.test('@ANDY hello')).toBe(true);
|
|
});
|
|
|
|
it('does not match when not at start of message', () => {
|
|
expect(TRIGGER_PATTERN.test('hello @Andy')).toBe(false);
|
|
});
|
|
|
|
it('does not match partial name like @Andrew (word boundary)', () => {
|
|
expect(TRIGGER_PATTERN.test('@Andrew hello')).toBe(false);
|
|
});
|
|
|
|
it('matches with word boundary before apostrophe', () => {
|
|
expect(TRIGGER_PATTERN.test("@Andy's thing")).toBe(true);
|
|
});
|
|
|
|
it('matches @Andy alone (end of string is a word boundary)', () => {
|
|
expect(TRIGGER_PATTERN.test('@Andy')).toBe(true);
|
|
});
|
|
|
|
it('matches with leading whitespace after trim', () => {
|
|
// The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim())
|
|
expect(TRIGGER_PATTERN.test('@Andy hey'.trim())).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- Outbound formatting (internal tag stripping + prefix) ---
|
|
|
|
describe('stripInternalTags', () => {
|
|
it('strips single-line internal tags', () => {
|
|
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe(
|
|
'hello world',
|
|
);
|
|
});
|
|
|
|
it('strips multi-line internal tags', () => {
|
|
expect(
|
|
stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world'),
|
|
).toBe('hello world');
|
|
});
|
|
|
|
it('strips multiple internal tag blocks', () => {
|
|
expect(
|
|
stripInternalTags(
|
|
'<internal>a</internal>hello<internal>b</internal>',
|
|
),
|
|
).toBe('hello');
|
|
});
|
|
|
|
it('returns empty string when text is only internal tags', () => {
|
|
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('formatOutbound', () => {
|
|
const waChannel = { prefixAssistantName: true } as Channel;
|
|
const noPrefixChannel = { prefixAssistantName: false } as Channel;
|
|
const defaultChannel = {} as Channel;
|
|
|
|
it('prefixes with assistant name when channel wants it', () => {
|
|
expect(formatOutbound(waChannel, 'hello world')).toBe(
|
|
`${ASSISTANT_NAME}: hello world`,
|
|
);
|
|
});
|
|
|
|
it('does not prefix when channel opts out', () => {
|
|
expect(formatOutbound(noPrefixChannel, 'hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('defaults to prefixing when prefixAssistantName is undefined', () => {
|
|
expect(formatOutbound(defaultChannel, 'hello world')).toBe(
|
|
`${ASSISTANT_NAME}: hello world`,
|
|
);
|
|
});
|
|
|
|
it('returns empty string when all text is internal', () => {
|
|
expect(formatOutbound(waChannel, '<internal>hidden</internal>')).toBe('');
|
|
});
|
|
|
|
it('strips internal tags and prefixes remaining text', () => {
|
|
expect(
|
|
formatOutbound(waChannel, '<internal>thinking</internal>The answer is 42'),
|
|
).toBe(`${ASSISTANT_NAME}: The answer is 42`);
|
|
});
|
|
});
|
|
|
|
// --- Trigger gating with requiresTrigger flag ---
|
|
|
|
describe('trigger gating (requiresTrigger interaction)', () => {
|
|
// Replicates the exact logic from processGroupMessages and startMessageLoop:
|
|
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
|
|
function shouldRequireTrigger(
|
|
isMainGroup: boolean,
|
|
requiresTrigger: boolean | undefined,
|
|
): boolean {
|
|
return !isMainGroup && requiresTrigger !== false;
|
|
}
|
|
|
|
function shouldProcess(
|
|
isMainGroup: boolean,
|
|
requiresTrigger: boolean | undefined,
|
|
messages: NewMessage[],
|
|
): boolean {
|
|
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
|
|
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
|
|
}
|
|
|
|
it('main group always processes (no trigger needed)', () => {
|
|
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
expect(shouldProcess(true, undefined, msgs)).toBe(true);
|
|
});
|
|
|
|
it('main group processes even with requiresTrigger=true', () => {
|
|
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
expect(shouldProcess(true, true, msgs)).toBe(true);
|
|
});
|
|
|
|
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
|
|
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
expect(shouldProcess(false, undefined, msgs)).toBe(false);
|
|
});
|
|
|
|
it('non-main group with requiresTrigger=true requires trigger', () => {
|
|
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
expect(shouldProcess(false, true, msgs)).toBe(false);
|
|
});
|
|
|
|
it('non-main group with requiresTrigger=true processes when trigger present', () => {
|
|
const msgs = [makeMsg({ content: '@Andy do something' })];
|
|
expect(shouldProcess(false, true, msgs)).toBe(true);
|
|
});
|
|
|
|
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
|
|
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
expect(shouldProcess(false, false, msgs)).toBe(true);
|
|
});
|
|
});
|