From 04f58e72c8b95e1a9d8f306b158204d210f213c0 Mon Sep 17 00:00:00 2001 From: Ari Webb Date: Fri, 6 Feb 2026 10:58:09 -0800 Subject: [PATCH] feat: add ergonomic channel management CLI (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ergonomic channel management CLI Add `lettabot channels` command for easier channel management: - `lettabot channels` - Interactive menu - `lettabot channels list` - Show status of all channels - `lettabot channels add ` - Add with focused setup - `lettabot channels remove ` - Remove/disable - `lettabot channels enable/disable ` - Quick toggle This makes it much easier to add a single channel without going through the full onboard wizard. For example, adding Discord after already having Telegram configured now only requires the Discord-specific prompts. Also fixes test for /reset command (was added but test not updated). šŸ™ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * refactor: remove enable/disable commands from channels CLI Simplify the channels CLI to just add/remove. The enable/disable commands were redundant - users can use `add` to reconfigure and `remove` to disable. šŸ™ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * clean up --------- Co-authored-by: Letta --- src/channels/index.ts | 1 + src/channels/setup.ts | 421 ++++++++++++++++++++++++++++++++++ src/cli.ts | 16 +- src/cli/channel-management.ts | 278 ++++++++++++++++++++++ src/core/commands.test.ts | 5 + src/onboard.ts | 328 +++----------------------- 6 files changed, 748 insertions(+), 301 deletions(-) create mode 100644 src/channels/setup.ts create mode 100644 src/cli/channel-management.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index 5a225b9..206ea91 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -3,6 +3,7 @@ */ export * from './types.js'; +export * from './setup.js'; export * from './telegram.js'; export * from './slack.js'; export * from './whatsapp/index.js'; diff --git a/src/channels/setup.ts b/src/channels/setup.ts new file mode 100644 index 0000000..1efc815 --- /dev/null +++ b/src/channels/setup.ts @@ -0,0 +1,421 @@ +/** + * Channel Setup Prompts + * + * Shared setup functions used by both onboard.ts and channel-management.ts. + * Each function takes existing config and returns the new config to save. + */ + +import { spawnSync } from 'node:child_process'; +import * as p from '@clack/prompts'; + +// ============================================================================ +// Channel Metadata +// ============================================================================ + +export const CHANNELS = [ + { id: 'telegram', displayName: 'Telegram', hint: 'Easiest to set up' }, + { id: 'slack', displayName: 'Slack', hint: 'Socket Mode app' }, + { id: 'discord', displayName: 'Discord', hint: 'Bot token + Message Content intent' }, + { id: 'whatsapp', displayName: 'WhatsApp', hint: 'QR code pairing' }, + { id: 'signal', displayName: 'Signal', hint: 'signal-cli daemon' }, +] as const; + +export type ChannelId = typeof CHANNELS[number]['id']; + +export function getChannelMeta(id: ChannelId) { + return CHANNELS.find(c => c.id === id)!; +} + +export function isSignalCliInstalled(): boolean { + return spawnSync('which', ['signal-cli'], { stdio: 'pipe' }).status === 0; +} + +export function getChannelHint(id: ChannelId): string { + if (id === 'signal' && !isSignalCliInstalled()) { + return 'āš ļø signal-cli not installed'; + } + return getChannelMeta(id).hint; +} + +// ============================================================================ +// Setup Functions +// ============================================================================ + +export async function setupTelegram(existing?: any): Promise { + p.note( + '1. Message @BotFather on Telegram\n' + + '2. Send /newbot and follow prompts\n' + + '3. Copy the bot token', + 'Telegram Setup' + ); + + const token = await p.text({ + message: 'Telegram Bot Token', + placeholder: '123456:ABC-DEF...', + initialValue: existing?.token || '', + }); + + if (p.isCancel(token)) { + p.cancel('Cancelled'); + process.exit(0); + } + + const dmPolicy = await p.select({ + message: 'Who can message the bot?', + options: [ + { value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' }, + { value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' }, + { value: 'open', label: 'Open', hint: 'Anyone (not recommended)' }, + ], + initialValue: existing?.dmPolicy || 'pairing', + }); + + if (p.isCancel(dmPolicy)) { + p.cancel('Cancelled'); + process.exit(0); + } + + let allowedUsers: string[] | undefined; + + if (dmPolicy === 'pairing') { + p.log.info('Users will get a code. Approve with: lettabot pairing approve telegram CODE'); + } else if (dmPolicy === 'allowlist') { + const users = await p.text({ + message: 'Allowed Telegram user IDs (comma-separated)', + placeholder: '123456789,987654321', + initialValue: existing?.allowedUsers?.join(',') || '', + }); + if (!p.isCancel(users) && users) { + allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); + } + } + + return { + enabled: true, + token: token || undefined, + dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open', + allowedUsers, + }; +} + +export async function setupSlack(existing?: any): Promise { + const hasExistingTokens = existing?.appToken || existing?.botToken; + + p.note( + 'Requires two tokens from api.slack.com/apps:\n' + + ' • App Token (xapp-...) - Socket Mode\n' + + ' • Bot Token (xoxb-...) - Bot permissions', + 'Slack Requirements' + ); + + const wizardChoice = await p.select({ + message: 'Slack setup', + options: [ + { value: 'wizard', label: 'Guided setup', hint: 'Step-by-step instructions with validation' }, + { value: 'manual', label: 'Manual entry', hint: 'I already have tokens' }, + ], + initialValue: hasExistingTokens ? 'manual' : 'wizard', + }); + + if (p.isCancel(wizardChoice)) { + p.cancel('Cancelled'); + process.exit(0); + } + + if (wizardChoice === 'wizard') { + const { runSlackWizard } = await import('../setup/slack-wizard.js'); + const result = await runSlackWizard({ + appToken: existing?.appToken, + botToken: existing?.botToken, + allowedUsers: existing?.allowedUsers, + }); + + if (result) { + return { + enabled: true, + appToken: result.appToken, + botToken: result.botToken, + allowedUsers: result.allowedUsers, + }; + } + return { enabled: false }; // Wizard cancelled + } + + // Manual entry + const { validateSlackTokens, stepAccessControl, validateAppToken, validateBotToken } = await import('../setup/slack-wizard.js'); + + p.note( + 'Get tokens from api.slack.com/apps:\n' + + '• Enable Socket Mode → App-Level Token (xapp-...)\n' + + '• Install App → Bot User OAuth Token (xoxb-...)\n\n' + + 'See docs/slack-setup.md for detailed instructions', + 'Slack Setup' + ); + + const appToken = await p.text({ + message: 'Slack App Token (xapp-...)', + initialValue: existing?.appToken || '', + validate: validateAppToken, + }); + + if (p.isCancel(appToken)) { + p.cancel('Cancelled'); + process.exit(0); + } + + const botToken = await p.text({ + message: 'Slack Bot Token (xoxb-...)', + initialValue: existing?.botToken || '', + validate: validateBotToken, + }); + + if (p.isCancel(botToken)) { + p.cancel('Cancelled'); + process.exit(0); + } + + if (appToken && botToken) { + await validateSlackTokens(appToken, botToken); + } + + const allowedUsers = await stepAccessControl(existing?.allowedUsers); + + return { + enabled: true, + appToken: appToken || undefined, + botToken: botToken || undefined, + allowedUsers, + }; +} + +export async function setupDiscord(existing?: any): Promise { + p.note( + '1. Go to discord.com/developers/applications\n' + + '2. Click "New Application" (or select existing)\n' + + '3. Go to "Bot" → Copy the Bot Token\n' + + '4. Enable "Message Content Intent" (under Privileged Gateway Intents)\n' + + '5. Go to "OAuth2" → "URL Generator"\n' + + ' • Scopes: bot\n' + + ' • Permissions: Send Messages, Read Message History, View Channels\n' + + '6. Copy the generated URL and open it to invite the bot to your server', + 'Discord Setup' + ); + + const token = await p.text({ + message: 'Discord Bot Token', + placeholder: 'Bot → Reset Token → Copy', + initialValue: existing?.token || '', + }); + + if (p.isCancel(token)) { + p.cancel('Cancelled'); + process.exit(0); + } + + // Try to show invite URL + if (token) { + try { + const appId = Buffer.from(token.split('.')[0], 'base64').toString(); + if (/^\d+$/.test(appId)) { + const inviteUrl = `https://discord.com/oauth2/authorize?client_id=${appId}&permissions=68608&scope=bot`; + p.log.info(`Invite URL: ${inviteUrl}`); + p.log.message('Open this URL in your browser to add the bot to your server.'); + } + } catch { + // Token parsing failed + } + } + + const dmPolicy = await p.select({ + message: 'Who can message the bot?', + options: [ + { value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' }, + { value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' }, + { value: 'open', label: 'Open', hint: 'Anyone (not recommended)' }, + ], + initialValue: existing?.dmPolicy || 'pairing', + }); + + if (p.isCancel(dmPolicy)) { + p.cancel('Cancelled'); + process.exit(0); + } + + let allowedUsers: string[] | undefined; + + if (dmPolicy === 'pairing') { + p.log.info('Users will get a code. Approve with: lettabot pairing approve discord CODE'); + } else if (dmPolicy === 'allowlist') { + const users = await p.text({ + message: 'Allowed Discord user IDs (comma-separated)', + placeholder: '123456789012345678,987654321098765432', + initialValue: existing?.allowedUsers?.join(',') || '', + }); + if (!p.isCancel(users) && users) { + allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); + } + } + + return { + enabled: true, + token: token || undefined, + dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open', + allowedUsers, + }; +} + +export async function setupWhatsApp(existing?: any): Promise { + p.note( + 'QR code will appear on first run - scan with your phone.\n' + + 'Phone: Settings → Linked Devices → Link a Device\n\n' + + 'āš ļø Security: Links as a full device to your WhatsApp account.\n' + + 'Can see ALL messages, not just ones sent to the bot.\n' + + 'Consider using a dedicated number for better isolation.', + 'WhatsApp' + ); + + const selfChat = await p.select({ + message: 'Whose number is this?', + options: [ + { value: 'personal', label: 'My personal number (recommended)', hint: 'SAFE: Only "Message Yourself" chat' }, + { value: 'dedicated', label: 'Dedicated bot number', hint: 'Bot responds to anyone who messages' }, + ], + initialValue: existing?.selfChat !== false ? 'personal' : 'dedicated', + }); + + if (p.isCancel(selfChat)) { + p.cancel('Cancelled'); + process.exit(0); + } + + const isSelfChat = selfChat === 'personal'; + + if (!isSelfChat) { + p.log.warn('Dedicated number mode: Bot will respond to ALL incoming messages.'); + p.log.warn('Only use this if this number is EXCLUSIVELY for the bot.'); + } + + let dmPolicy: 'pairing' | 'allowlist' | 'open' = 'pairing'; + let allowedUsers: string[] | undefined; + + if (!isSelfChat) { + dmPolicy = 'allowlist'; + const users = await p.text({ + message: 'Allowed phone numbers (comma-separated, with +)', + placeholder: '+15551234567,+15559876543', + initialValue: existing?.allowedUsers?.join(',') || '', + }); + if (!p.isCancel(users) && users) { + allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); + } + if (!allowedUsers?.length) { + p.log.warn('No allowed numbers set. Bot will reject all messages until you add numbers to lettabot.yaml'); + } + } + + p.log.info('Run "lettabot server" to see the QR code and complete pairing.'); + + return { + enabled: true, + selfChat: isSelfChat, + dmPolicy, + allowedUsers, + }; +} + +export async function setupSignal(existing?: any): Promise { + const signalInstalled = isSignalCliInstalled(); + + if (!signalInstalled) { + p.log.warn('signal-cli is not installed.'); + p.log.info('Install with: brew install signal-cli'); + + const continueAnyway = await p.confirm({ + message: 'Continue setup anyway?', + initialValue: false, + }); + + if (p.isCancel(continueAnyway) || !continueAnyway) { + p.cancel('Cancelled'); + process.exit(0); + } + } + + p.note( + 'See docs/signal-setup.md for detailed instructions.\n' + + 'Requires signal-cli registered with your phone number.\n\n' + + 'āš ļø Security: Has full access to your Signal account.\n' + + 'Can see all messages and send as you.', + 'Signal Setup' + ); + + const phone = await p.text({ + message: 'Signal phone number', + placeholder: '+1XXXXXXXXXX', + initialValue: existing?.phone || '', + }); + + if (p.isCancel(phone)) { + p.cancel('Cancelled'); + process.exit(0); + } + + const selfChat = await p.select({ + message: 'Whose number is this?', + options: [ + { value: 'personal', label: 'My personal number (recommended)', hint: 'SAFE: Only "Note to Self" chat' }, + { value: 'dedicated', label: 'Dedicated bot number', hint: 'Bot responds to anyone who messages' }, + ], + initialValue: existing?.selfChat !== false ? 'personal' : 'dedicated', + }); + + if (p.isCancel(selfChat)) { + p.cancel('Cancelled'); + process.exit(0); + } + + const isSelfChat = selfChat === 'personal'; + + if (!isSelfChat) { + p.log.warn('Dedicated number mode: Bot will respond to ALL incoming messages.'); + p.log.warn('Only use this if this number is EXCLUSIVELY for the bot.'); + } + + let dmPolicy: 'pairing' | 'allowlist' | 'open' = 'pairing'; + let allowedUsers: string[] | undefined; + + if (!isSelfChat) { + dmPolicy = 'allowlist'; + const users = await p.text({ + message: 'Allowed phone numbers (comma-separated, with +)', + placeholder: '+15551234567,+15559876543', + initialValue: existing?.allowedUsers?.join(',') || '', + }); + if (!p.isCancel(users) && users) { + allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); + } + if (!allowedUsers?.length) { + p.log.warn('No allowed numbers set. Bot will reject all messages until you add numbers to lettabot.yaml'); + } + } + + return { + enabled: true, + phone: phone || undefined, + selfChat: isSelfChat, + dmPolicy, + allowedUsers, + }; +} + +/** Get the setup function for a channel */ +export function getSetupFunction(id: ChannelId): (existing?: any) => Promise { + const setupFunctions: Record Promise> = { + telegram: setupTelegram, + slack: setupSlack, + discord: setupDiscord, + whatsapp: setupWhatsApp, + signal: setupSignal, + }; + return setupFunctions[id]; +} diff --git a/src/cli.ts b/src/cli.ts index f504667..c258f01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -190,6 +190,10 @@ Commands: onboard Setup wizard (integrations, skills, configuration) server Start the bot server configure View and edit configuration + channels Manage channels (interactive menu) + channels list Show channel status + channels add Add a channel (telegram, slack, discord, whatsapp, signal) + channels remove Remove a channel logout Logout from Letta Platform (revoke OAuth tokens) skills Configure which skills are enabled skills status Show skills status @@ -202,6 +206,9 @@ Commands: Examples: lettabot onboard # First-time setup lettabot server # Start the bot + lettabot channels # Interactive channel management + lettabot channels add discord # Add Discord integration + lettabot channels remove telegram # Remove Telegram lettabot pairing list telegram # Show pending Telegram pairings lettabot pairing approve telegram ABCD1234 # Approve a pairing code @@ -250,6 +257,13 @@ async function main() { break; } + case 'channels': + case 'channel': { + const { channelManagementCommand } = await import('./cli/channel-management.js'); + await channelManagementCommand(subCommand, args[2]); + break; + } + case 'pairing': { const channel = subCommand; const action = args[2]; @@ -443,7 +457,7 @@ async function main() { case undefined: console.log('Usage: lettabot \n'); - console.log('Commands: onboard, server, configure, skills, reset-conversation, destroy, help\n'); + console.log('Commands: onboard, server, configure, channels, skills, reset-conversation, destroy, help\n'); console.log('Run "lettabot help" for more information.'); break; diff --git a/src/cli/channel-management.ts b/src/cli/channel-management.ts new file mode 100644 index 0000000..109751f --- /dev/null +++ b/src/cli/channel-management.ts @@ -0,0 +1,278 @@ +/** + * Channel Management CLI + * + * Ergonomic commands for adding, removing, and managing channels. + * Uses shared setup functions from src/channels/setup.ts. + */ + +import * as p from '@clack/prompts'; +import { loadConfig, saveConfig, resolveConfigPath } from '../config/index.js'; +import { + CHANNELS, + getChannelHint, + getSetupFunction, + type ChannelId +} from '../channels/setup.js'; + +// ============================================================================ +// Status Helpers +// ============================================================================ + +interface ChannelStatus { + id: ChannelId; + displayName: string; + enabled: boolean; + hint: string; + details?: string; +} + +function getChannelDetails(id: ChannelId, channelConfig: any): string | undefined { + if (!channelConfig?.enabled) return undefined; + + switch (id) { + case 'telegram': + case 'discord': + return `${channelConfig.dmPolicy || 'pairing'} mode`; + case 'slack': + return channelConfig.allowedUsers?.length + ? `${channelConfig.allowedUsers.length} allowed users` + : 'workspace access'; + case 'whatsapp': + case 'signal': + return channelConfig.selfChat !== false ? 'self-chat mode' : 'dedicated number'; + default: + return undefined; + } +} + +function getChannelStatus(): ChannelStatus[] { + const config = loadConfig(); + + return CHANNELS.map(ch => { + const channelConfig = config.channels[ch.id as keyof typeof config.channels]; + return { + id: ch.id, + displayName: ch.displayName, + enabled: channelConfig?.enabled || false, + hint: getChannelHint(ch.id), + details: getChannelDetails(ch.id, channelConfig), + }; + }); +} + +// ============================================================================ +// Commands +// ============================================================================ + +export async function listChannels(): Promise { + const channels = getChannelStatus(); + + console.log('\nšŸ”Œ Channel Status\n'); + console.log(' Channel Status Details'); + console.log(' ──────────────────────────────────────────'); + + for (const ch of channels) { + const status = ch.enabled ? 'āœ“ Enabled ' : 'āœ— Disabled'; + const details = ch.details || ch.hint; + console.log(` ${ch.displayName.padEnd(10)} ${status} ${details}`); + } + + console.log('\n Config: ' + resolveConfigPath()); + console.log(''); +} + +export async function interactiveChannelMenu(): Promise { + p.intro('šŸ”Œ Channel Management'); + + const channels = getChannelStatus(); + const enabledCount = channels.filter(c => c.enabled).length; + + const statusLines = channels.map(ch => { + const status = ch.enabled ? 'āœ“' : 'āœ—'; + const details = ch.enabled && ch.details ? ` (${ch.details})` : ''; + return ` ${status} ${ch.displayName}${details}`; + }); + + p.note(statusLines.join('\n'), `${enabledCount} of ${channels.length} channels enabled`); + + const action = await p.select({ + message: 'What would you like to do?', + options: [ + { value: 'add', label: 'Add a channel', hint: 'Set up a new integration' }, + { value: 'remove', label: 'Remove a channel', hint: 'Disable and clear config' }, + { value: 'edit', label: 'Edit channel settings', hint: 'Update existing config' }, + { value: 'exit', label: 'Exit', hint: '' }, + ], + }); + + if (p.isCancel(action) || action === 'exit') { + p.outro(''); + return; + } + + switch (action) { + case 'add': { + const disabled = channels.filter(c => !c.enabled); + if (disabled.length === 0) { + p.log.info('All channels are already enabled.'); + return interactiveChannelMenu(); + } + + const channel = await p.select({ + message: 'Which channel would you like to add?', + options: disabled.map(c => ({ value: c.id, label: c.displayName, hint: c.hint })), + }); + + if (!p.isCancel(channel)) { + await addChannel(channel as ChannelId); + } + break; + } + + case 'remove': { + const enabled = channels.filter(c => c.enabled); + if (enabled.length === 0) { + p.log.info('No channels are enabled.'); + return interactiveChannelMenu(); + } + + const channel = await p.select({ + message: 'Which channel would you like to remove?', + options: enabled.map(c => ({ value: c.id, label: c.displayName, hint: c.details || '' })), + }); + + if (!p.isCancel(channel)) { + await removeChannel(channel as ChannelId); + } + break; + } + + case 'edit': { + const enabled = channels.filter(c => c.enabled); + if (enabled.length === 0) { + p.log.info('No channels are enabled. Add a channel first.'); + return interactiveChannelMenu(); + } + + const channel = await p.select({ + message: 'Which channel would you like to edit?', + options: enabled.map(c => ({ value: c.id, label: c.displayName, hint: c.details || '' })), + }); + + if (!p.isCancel(channel)) { + await addChannel(channel as ChannelId); + } + break; + } + } + + p.outro(''); +} + +export async function addChannel(channelId?: string): Promise { + if (!channelId) { + p.intro('šŸ”Œ Add Channel'); + + const channels = getChannelStatus(); + const disabled = channels.filter(c => !c.enabled); + + if (disabled.length === 0) { + p.log.info('All channels are already enabled.'); + p.outro(''); + return; + } + + const selected = await p.select({ + message: 'Which channel would you like to add?', + options: disabled.map(c => ({ value: c.id, label: c.displayName, hint: c.hint })), + }); + + if (p.isCancel(selected)) { + p.cancel('Cancelled'); + return; + } + + channelId = selected as string; + } + + const channelIds = CHANNELS.map(c => c.id); + if (!channelIds.includes(channelId as ChannelId)) { + console.error(`Unknown channel: ${channelId}`); + console.error(`Valid channels: ${channelIds.join(', ')}`); + process.exit(1); + } + + const config = loadConfig(); + const existingConfig = config.channels[channelId as keyof typeof config.channels]; + + // Get and run the setup function + const setup = getSetupFunction(channelId as ChannelId); + const newConfig = await setup(existingConfig); + + // Save + (config.channels as any)[channelId] = newConfig; + saveConfig(config); + p.log.success(`Configuration saved to ${resolveConfigPath()}`); +} + +export async function removeChannel(channelId?: string): Promise { + const channelIds = CHANNELS.map(c => c.id); + + if (!channelId) { + console.error('Usage: lettabot channels remove '); + console.error(`Valid channels: ${channelIds.join(', ')}`); + process.exit(1); + } + + if (!channelIds.includes(channelId as ChannelId)) { + console.error(`Unknown channel: ${channelId}`); + console.error(`Valid channels: ${channelIds.join(', ')}`); + process.exit(1); + } + + const config = loadConfig(); + const channelConfig = config.channels[channelId as keyof typeof config.channels]; + + if (!channelConfig?.enabled) { + console.log(`${channelId} is already disabled.`); + return; + } + + const meta = CHANNELS.find(c => c.id === channelId)!; + const confirmed = await p.confirm({ + message: `Remove ${meta.displayName}? This will disable the channel.`, + initialValue: false, + }); + + if (p.isCancel(confirmed) || !confirmed) { + p.cancel('Cancelled'); + return; + } + + (config.channels as any)[channelId] = { enabled: false }; + saveConfig(config); + p.log.success(`${meta.displayName} disabled`); +} + +// ============================================================================ +// Main Command Handler +// ============================================================================ + +export async function channelManagementCommand(subCommand?: string, channelName?: string): Promise { + switch (subCommand) { + case 'list': + case 'ls': + await listChannels(); + break; + case 'add': + await addChannel(channelName); + break; + case 'remove': + case 'rm': + await removeChannel(channelName); + break; + default: + await interactiveChannelMenu(); + break; + } +} diff --git a/src/core/commands.test.ts b/src/core/commands.test.ts index f0b252e..e350052 100644 --- a/src/core/commands.test.ts +++ b/src/core/commands.test.ts @@ -18,6 +18,10 @@ describe('parseCommand', () => { it('returns "start" for /start', () => { expect(parseCommand('/start')).toBe('start'); }); + + it('returns "reset" for /reset', () => { + expect(parseCommand('/reset')).toBe('reset'); + }); }); describe('invalid input', () => { @@ -64,6 +68,7 @@ describe('COMMANDS', () => { it('contains all expected commands', () => { expect(COMMANDS).toContain('status'); expect(COMMANDS).toContain('heartbeat'); + expect(COMMANDS).toContain('reset'); expect(COMMANDS).toContain('help'); expect(COMMANDS).toContain('start'); expect(COMMANDS).toContain('reset'); diff --git a/src/onboard.ts b/src/onboard.ts index c42e937..5d741b8 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -9,6 +9,7 @@ import * as p from '@clack/prompts'; import { saveConfig, syncProviders } from './config/index.js'; import type { LettaBotConfig, ProviderConfig } from './config/types.js'; import { isLettaCloudUrl } from './utils/server.js'; +import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js'; // ============================================================================ // Non-Interactive Helpers @@ -582,23 +583,14 @@ async function stepModel(config: OnboardConfig, env: Record): Pr } async function stepChannels(config: OnboardConfig, env: Record): Promise { - // Check if signal-cli is installed - const signalInstalled = spawnSync('which', ['signal-cli'], { stdio: 'pipe' }).status === 0; + // Build channel options from shared CHANNELS array + const channelOptions = CHANNELS.map(ch => ({ + value: ch.id, + label: ch.displayName, + hint: getChannelHint(ch.id), + })); - // Build channel options - show all channels, disabled ones have explanatory hints - const channelOptions: Array<{ value: string; label: string; hint: string }> = [ - { value: 'telegram', label: 'Telegram', hint: 'Recommended - easiest to set up' }, - { value: 'slack', label: 'Slack', hint: 'Socket Mode app' }, - { value: 'discord', label: 'Discord', hint: 'Bot token + Message Content intent' }, - { value: 'whatsapp', label: 'WhatsApp', hint: 'QR code pairing' }, - { - value: 'signal', - label: 'Signal', - hint: signalInstalled ? 'signal-cli daemon' : 'āš ļø signal-cli not installed' - }, - ]; - - // Pre-select channels that are already enabled (preserves existing config) + // Pre-select channels that are already enabled const initialChannels: string[] = []; if (config.telegram.enabled) initialChannels.push('telegram'); if (config.slack.enabled) initialChannels.push('slack'); @@ -619,7 +611,6 @@ async function stepChannels(config: OnboardConfig, env: Record): channels = selectedChannels as string[]; - // Confirm if no channels selected if (channels.length === 0) { const skipChannels = await p.confirm({ message: 'No channels selected. Continue without any messaging channels?', @@ -627,312 +618,49 @@ async function stepChannels(config: OnboardConfig, env: Record): }); if (p.isCancel(skipChannels)) { p.cancel('Setup cancelled'); process.exit(0); } if (skipChannels) break; - // Otherwise loop back to selection } else { break; } } + // Handle Signal warning if selected but not installed + const signalInstalled = isSignalCliInstalled(); + if (channels.includes('signal') && !signalInstalled) { + p.log.warn('Signal selected but signal-cli is not installed. Install with: brew install signal-cli'); + channels = channels.filter(c => c !== 'signal'); + } + // Update enabled states config.telegram.enabled = channels.includes('telegram'); config.slack.enabled = channels.includes('slack'); config.discord.enabled = channels.includes('discord'); config.whatsapp.enabled = channels.includes('whatsapp'); + config.signal.enabled = channels.includes('signal'); - // Handle Signal - warn if selected but not installed - if (channels.includes('signal') && !signalInstalled) { - p.log.warn('Signal selected but signal-cli is not installed. Install with: brew install signal-cli'); - config.signal.enabled = false; - } else { - config.signal.enabled = channels.includes('signal'); - } - - // Configure each selected channel + // Configure each selected channel using shared setup functions if (config.telegram.enabled) { - p.note( - '1. Message @BotFather on Telegram\n' + - '2. Send /newbot and follow prompts\n' + - '3. Copy the bot token', - 'Telegram Setup' - ); - - const token = await p.text({ - message: 'Telegram Bot Token', - placeholder: '123456:ABC-DEF...', - initialValue: config.telegram.token || '', - }); - if (!p.isCancel(token) && token) config.telegram.token = token; - - // Access control - const dmPolicy = await p.select({ - message: 'Telegram: Who can message the bot?', - options: [ - { value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' }, - { value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' }, - { value: 'open', label: 'Open', hint: 'Anyone (not recommended)' }, - ], - initialValue: config.telegram.dmPolicy || 'pairing', - }); - if (!p.isCancel(dmPolicy)) { - config.telegram.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open'; - - if (dmPolicy === 'pairing') { - p.log.info('Users will get a code. Approve with: lettabot pairing approve telegram CODE'); - } else if (dmPolicy === 'allowlist') { - const users = await p.text({ - message: 'Allowed Telegram user IDs (comma-separated)', - placeholder: '123456789,987654321', - initialValue: config.telegram.allowedUsers?.join(',') || '', - }); - if (!p.isCancel(users) && users) { - config.telegram.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); - } - } - } + const result = await setupTelegram(config.telegram); + Object.assign(config.telegram, result); } if (config.slack.enabled) { - const hasExistingTokens = config.slack.appToken || config.slack.botToken; - - // Show what's needed - p.note( - 'Requires two tokens from api.slack.com/apps:\n' + - ' • App Token (xapp-...) - Socket Mode\n' + - ' • Bot Token (xoxb-...) - Bot permissions', - 'Slack Requirements' - ); - - const wizardChoice = await p.select({ - message: 'Slack setup', - options: [ - { value: 'wizard', label: 'Guided setup', hint: 'Step-by-step instructions with validation' }, - { value: 'manual', label: 'Manual entry', hint: 'I already have tokens' }, - ], - initialValue: hasExistingTokens ? 'manual' : 'wizard', - }); - - if (p.isCancel(wizardChoice)) { - p.cancel('Setup cancelled'); - process.exit(0); - } - - if (wizardChoice === 'wizard') { - const { runSlackWizard } = await import('./setup/slack-wizard.js'); - const result = await runSlackWizard({ - appToken: config.slack.appToken, - botToken: config.slack.botToken, - allowedUsers: config.slack.allowedUsers, - }); - - if (result) { - config.slack.appToken = result.appToken; - config.slack.botToken = result.botToken; - config.slack.allowedUsers = result.allowedUsers; - } else { - // Wizard was cancelled, disable Slack - config.slack.enabled = false; - } - } else { - // Manual token entry with validation - const { validateSlackTokens, stepAccessControl, validateAppToken, validateBotToken } = await import('./setup/slack-wizard.js'); - - p.note( - 'Get tokens from api.slack.com/apps:\n' + - '• Enable Socket Mode → App-Level Token (xapp-...)\n' + - '• Install App → Bot User OAuth Token (xoxb-...)\n\n' + - 'See docs/slack-setup.md for detailed instructions', - 'Slack Setup' - ); - - const appToken = await p.text({ - message: 'Slack App Token (xapp-...)', - initialValue: config.slack.appToken || '', - validate: validateAppToken, - }); - if (p.isCancel(appToken)) { - config.slack.enabled = false; - } else { - config.slack.appToken = appToken; - } - - const botToken = await p.text({ - message: 'Slack Bot Token (xoxb-...)', - initialValue: config.slack.botToken || '', - validate: validateBotToken, - }); - if (p.isCancel(botToken)) { - config.slack.enabled = false; - } else { - config.slack.botToken = botToken; - } - - // Validate tokens if both provided - if (config.slack.appToken && config.slack.botToken) { - await validateSlackTokens(config.slack.appToken, config.slack.botToken); - } - - // Slack access control (reuse wizard step) - const allowedUsers = await stepAccessControl(config.slack.allowedUsers); - if (allowedUsers !== undefined) { - config.slack.allowedUsers = allowedUsers; - } - } + const result = await setupSlack(config.slack); + Object.assign(config.slack, result); } - + if (config.discord.enabled) { - p.note( - '1. Go to discord.com/developers/applications\n' + - '2. Click "New Application" (or select existing)\n' + - '3. Go to "Bot" → Copy the Bot Token\n' + - '4. Enable "Message Content Intent" (under Privileged Gateway Intents)\n' + - '5. Go to "OAuth2" → "URL Generator"\n' + - ' • Scopes: bot\n' + - ' • Permissions: Send Messages, Read Message History, View Channels\n' + - '6. Copy the generated URL and open it to invite the bot to your server', - 'Discord Setup' - ); - - const token = await p.text({ - message: 'Discord Bot Token', - placeholder: 'Bot → Reset Token → Copy', - initialValue: config.discord.token || '', - }); - if (!p.isCancel(token) && token) { - config.discord.token = token; - - // Extract application ID from token and show invite URL - // Token format: base64(app_id).timestamp.hmac - try { - const appId = Buffer.from(token.split('.')[0], 'base64').toString(); - if (/^\d+$/.test(appId)) { - // permissions=68608 = Send Messages (2048) + Read Message History (65536) + View Channels (1024) - const inviteUrl = `https://discord.com/oauth2/authorize?client_id=${appId}&permissions=68608&scope=bot`; - p.log.info(`Invite URL: ${inviteUrl}`); - p.log.message('Open this URL in your browser to add the bot to your server.'); - } - } catch { - // Token parsing failed, skip showing URL - } - } - - const dmPolicy = await p.select({ - message: 'Discord: Who can message the bot?', - options: [ - { value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' }, - { value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' }, - { value: 'open', label: 'Open', hint: 'Anyone (not recommended)' }, - ], - initialValue: config.discord.dmPolicy || 'pairing', - }); - if (!p.isCancel(dmPolicy)) { - config.discord.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open'; - - if (dmPolicy === 'pairing') { - p.log.info('Users will get a code. Approve with: lettabot pairing approve discord CODE'); - } else if (dmPolicy === 'allowlist') { - const users = await p.text({ - message: 'Allowed Discord user IDs (comma-separated)', - placeholder: '123456789012345678,987654321098765432', - initialValue: config.discord.allowedUsers?.join(',') || '', - }); - if (!p.isCancel(users) && users) { - config.discord.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); - } - } - } + const result = await setupDiscord(config.discord); + Object.assign(config.discord, result); } if (config.whatsapp.enabled) { - p.note( - 'QR code will appear on first run - scan with your phone.\n' + - 'Phone: Settings → Linked Devices → Link a Device\n\n' + - 'āš ļø Security: Links as a full device to your WhatsApp account.\n' + - 'Can see ALL messages, not just ones sent to the bot.\n' + - 'Consider using a dedicated number for better isolation.', - 'WhatsApp' - ); - - const selfChat = await p.select({ - message: 'WhatsApp: Whose number is this?', - options: [ - { value: 'personal', label: 'My personal number (recommended)', hint: 'SAFE: Only "Message Yourself" chat - your contacts never see the bot' }, - { value: 'dedicated', label: 'Dedicated bot number', hint: 'Bot responds to anyone who messages this number' }, - ], - initialValue: config.whatsapp.selfChat !== false ? 'personal' : 'dedicated', - }); - if (!p.isCancel(selfChat)) { - config.whatsapp.selfChat = selfChat === 'personal'; - if (selfChat === 'dedicated') { - p.log.warn('Dedicated number mode: Bot will respond to ALL incoming messages.'); - p.log.warn('Only use this if this number is EXCLUSIVELY for the bot.'); - } - } - - // Dedicated numbers use allowlist by default - if (config.whatsapp.selfChat === false) { - config.whatsapp.dmPolicy = 'allowlist'; - const users = await p.text({ - message: 'Allowed phone numbers (comma-separated, with +)', - placeholder: '+15551234567,+15559876543', - initialValue: config.whatsapp.allowedUsers?.join(',') || '', - }); - if (!p.isCancel(users) && users) { - config.whatsapp.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); - } - if (!config.whatsapp.allowedUsers?.length) { - p.log.warn('No allowed numbers set. Bot will reject all messages until you add numbers to lettabot.yaml'); - } - } + const result = await setupWhatsApp(config.whatsapp); + Object.assign(config.whatsapp, result); } if (config.signal.enabled) { - p.note( - 'See docs/signal-setup.md for detailed instructions.\n' + - 'Requires signal-cli registered with your phone number.\n\n' + - 'āš ļø Security: Has full access to your Signal account.\n' + - 'Can see all messages and send as you.', - 'Signal Setup' - ); - - const phone = await p.text({ - message: 'Signal phone number', - placeholder: '+1XXXXXXXXXX', - initialValue: config.signal.phone || '', - }); - if (!p.isCancel(phone) && phone) config.signal.phone = phone; - - const selfChat = await p.select({ - message: 'Signal: Whose number is this?', - options: [ - { value: 'personal', label: 'My personal number (recommended)', hint: 'SAFE: Only "Note to Self" chat - your contacts never see the bot' }, - { value: 'dedicated', label: 'Dedicated bot number', hint: 'Bot responds to anyone who messages this number' }, - ], - initialValue: config.signal.selfChat !== false ? 'personal' : 'dedicated', - }); - if (!p.isCancel(selfChat)) { - config.signal.selfChat = selfChat === 'personal'; - if (selfChat === 'dedicated') { - p.log.warn('Dedicated number mode: Bot will respond to ALL incoming messages.'); - p.log.warn('Only use this if this number is EXCLUSIVELY for the bot.'); - } - } - - // Access control only matters for dedicated numbers - // Dedicated numbers use allowlist by default - if (config.signal.selfChat === false) { - config.signal.dmPolicy = 'allowlist'; - const users = await p.text({ - message: 'Allowed phone numbers (comma-separated, with +)', - placeholder: '+15551234567,+15559876543', - initialValue: config.signal.allowedUsers?.join(',') || '', - }); - if (!p.isCancel(users) && users) { - config.signal.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); - } - if (!config.signal.allowedUsers?.length) { - p.log.warn('No allowed numbers set. Bot will reject all messages until you add numbers to lettabot.yaml'); - } - } + const result = await setupSignal(config.signal); + Object.assign(config.signal, result); } }