* 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>
258 lines
8.0 KiB
TypeScript
258 lines
8.0 KiB
TypeScript
import { 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<string, RegisteredGroup>;
|
|
}
|
|
|
|
export class TelegramChannel implements Channel {
|
|
name = 'telegram';
|
|
|
|
private bot: Bot | null = null;
|
|
private opts: TelegramChannelOpts;
|
|
private botToken: string;
|
|
|
|
constructor(botToken: string, opts: TelegramChannelOpts) {
|
|
this.botToken = botToken;
|
|
this.opts = opts;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
this.bot = new Bot(this.botToken);
|
|
|
|
// Command to get chat ID (useful for registration)
|
|
this.bot.command('chatid', (ctx) => {
|
|
const chatId = ctx.chat.id;
|
|
const chatType = ctx.chat.type;
|
|
const chatName =
|
|
chatType === 'private'
|
|
? ctx.from?.first_name || 'Private'
|
|
: (ctx.chat as any).title || 'Unknown';
|
|
|
|
ctx.reply(
|
|
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
|
|
{ parse_mode: 'Markdown' },
|
|
);
|
|
});
|
|
|
|
// Command to check bot status
|
|
this.bot.command('ping', (ctx) => {
|
|
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
|
});
|
|
|
|
this.bot.on('message:text', async (ctx) => {
|
|
// Skip commands
|
|
if (ctx.message.text.startsWith('/')) return;
|
|
|
|
const chatJid = `tg:${ctx.chat.id}`;
|
|
let content = ctx.message.text;
|
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
|
const senderName =
|
|
ctx.from?.first_name ||
|
|
ctx.from?.username ||
|
|
ctx.from?.id.toString() ||
|
|
'Unknown';
|
|
const sender = ctx.from?.id.toString() || '';
|
|
const msgId = ctx.message.message_id.toString();
|
|
|
|
// Determine chat name
|
|
const chatName =
|
|
ctx.chat.type === 'private'
|
|
? senderName
|
|
: (ctx.chat as any).title || chatJid;
|
|
|
|
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
|
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
|
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
|
const botUsername = ctx.me?.username?.toLowerCase();
|
|
if (botUsername) {
|
|
const entities = ctx.message.entities || [];
|
|
const isBotMentioned = entities.some((entity) => {
|
|
if (entity.type === 'mention') {
|
|
const mentionText = content
|
|
.substring(entity.offset, entity.offset + entity.length)
|
|
.toLowerCase();
|
|
return mentionText === `@${botUsername}`;
|
|
}
|
|
return false;
|
|
});
|
|
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
|
content = `@${ASSISTANT_NAME} ${content}`;
|
|
}
|
|
}
|
|
|
|
// Store chat metadata for discovery
|
|
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<void>((resolve) => {
|
|
this.bot!.start({
|
|
onStart: (botInfo) => {
|
|
logger.info(
|
|
{ username: botInfo.username, id: botInfo.id },
|
|
'Telegram bot connected',
|
|
);
|
|
console.log(`\n Telegram bot: @${botInfo.username}`);
|
|
console.log(
|
|
` Send /chatid to the bot to get a chat's registration ID\n`,
|
|
);
|
|
resolve();
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async sendMessage(jid: string, text: string): Promise<void> {
|
|
if (!this.bot) {
|
|
logger.warn('Telegram bot not initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const numericId = jid.replace(/^tg:/, '');
|
|
|
|
// Telegram has a 4096 character limit per message — split if needed
|
|
const MAX_LENGTH = 4096;
|
|
if (text.length <= MAX_LENGTH) {
|
|
await this.bot.api.sendMessage(numericId, text);
|
|
} else {
|
|
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
|
await this.bot.api.sendMessage(
|
|
numericId,
|
|
text.slice(i, i + MAX_LENGTH),
|
|
);
|
|
}
|
|
}
|
|
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
|
} catch (err) {
|
|
logger.error({ jid, err }, 'Failed to send Telegram message');
|
|
}
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.bot !== null;
|
|
}
|
|
|
|
ownsJid(jid: string): boolean {
|
|
return jid.startsWith('tg:');
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
if (this.bot) {
|
|
this.bot.stop();
|
|
this.bot = null;
|
|
logger.info('Telegram bot stopped');
|
|
}
|
|
}
|
|
|
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
|
if (!this.bot || !isTyping) return;
|
|
try {
|
|
const numericId = jid.replace(/^tg:/, '');
|
|
await this.bot.api.sendChatAction(numericId, 'typing');
|
|
} catch (err) {
|
|
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|