/** * 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; } // ============================================================================ // Group ID hints per channel // ============================================================================ const GROUP_ID_HINTS: Record = { telegram: 'Group IDs are negative numbers (e.g., -1001234567890).\n' + 'Forward a group message to @userinfobot, or check bot logs.', discord: 'Enable Developer Mode in Settings > Advanced,\n' + 'then right-click a channel/server > Copy ID.', slack: 'Right-click channel > Copy link > extract ID,\n' + 'or Channel Details > Copy Channel ID (e.g., C0123456789).', whatsapp: 'Group JIDs appear in bot logs on first message\n' + '(e.g., 120363123456@g.us).', signal: 'Group IDs appear in bot logs on first group message.', }; // ============================================================================ // Setup Functions // ============================================================================ type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled'; /** * Derive the initial group mode from existing config. * Reads modern groups config first, falls back to deprecated fields. */ function deriveExistingMode(existing?: any): GroupMode | undefined { // Modern: groups.*.mode const wildcardMode = existing?.groups?.['*']?.mode; if (wildcardMode) return wildcardMode as GroupMode; // Deprecated: listeningGroups implies "listen" was the intent if (existing?.listeningGroups?.length > 0) return 'listen'; return undefined; } async function promptGroupSettings( channelId: ChannelId, existing?: any, ): Promise<{ groups?: Record; groupDebounceSec?: number; }> { const existingMode = deriveExistingMode(existing); const hasExisting = existingMode !== undefined || existing?.groupDebounceSec !== undefined || (existing?.groups && Object.keys(existing.groups).length > 0); const configure = await p.confirm({ message: 'Configure group chat settings?', initialValue: hasExisting, }); if (p.isCancel(configure)) { p.cancel('Cancelled'); process.exit(0); } if (!configure) { // Preserve existing config as-is return { groups: existing?.groups, groupDebounceSec: existing?.groupDebounceSec, }; } // Step 1: Default group mode const mode = await p.select({ message: 'Default group behavior', options: [ { value: 'mention-only', label: 'Mention-only (recommended)', hint: 'Only respond when @mentioned' }, { value: 'listen', label: 'Listen', hint: 'Read all messages, only respond when mentioned' }, { value: 'open', label: 'Open', hint: 'Respond to all group messages' }, { value: 'disabled', label: 'Disabled', hint: 'Ignore all group messages' }, ], initialValue: existingMode || 'mention-only', }); if (p.isCancel(mode)) { p.cancel('Cancelled'); process.exit(0); } // Step 2: Debounce (skip for disabled) let groupDebounceSec: number | undefined = existing?.groupDebounceSec; if (mode !== 'disabled') { const debounceRaw = await p.text({ message: 'Group debounce seconds (blank = 5s default)', placeholder: '5', initialValue: existing?.groupDebounceSec !== undefined ? String(existing.groupDebounceSec) : '', validate: (value) => { const trimmed = value.trim(); if (!trimmed) return undefined; const num = Number(trimmed); if (!Number.isFinite(num) || num < 0) return 'Enter a non-negative number or leave blank'; return undefined; }, }); if (p.isCancel(debounceRaw)) { p.cancel('Cancelled'); process.exit(0); } const debounceValue = typeof debounceRaw === 'string' ? debounceRaw.trim() : ''; groupDebounceSec = debounceValue ? Number(debounceValue) : undefined; } // Step 3: Channel-specific hint for finding group IDs const hint = GROUP_ID_HINTS[channelId]; if (hint && mode !== 'disabled') { p.note( hint + '\n\n' + 'Tip: Start with this default and check logs for IDs.\n' + 'Add per-group overrides in lettabot.yaml later.', 'Finding Group IDs' ); } // Build groups config: set wildcard default, preserve any existing per-group overrides const groups: Record = {}; // Carry over existing per-group entries (non-wildcard) if (existing?.groups) { for (const [key, value] of Object.entries(existing.groups)) { if (key !== '*') { groups[key] = value; } } } // Set the wildcard default groups['*'] = { mode: mode as GroupMode }; return { groups, groupDebounceSec, }; } 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); } } const groupSettings = await promptGroupSettings('telegram', existing); return { enabled: true, token: token || undefined, dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open', allowedUsers, ...groupSettings, }; } 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) { const groupSettings = await promptGroupSettings('slack', existing); return { enabled: true, appToken: result.appToken, botToken: result.botToken, allowedUsers: result.allowedUsers, ...groupSettings, }; } 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); const groupSettings = await promptGroupSettings('slack', existing); return { enabled: true, appToken: appToken || undefined, botToken: botToken || undefined, allowedUsers, ...groupSettings, }; } 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); } } const groupSettings = await promptGroupSettings('discord', existing); return { enabled: true, token: token || undefined, dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open', allowedUsers, ...groupSettings, }; } 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'); } } const groupSettings = await promptGroupSettings('whatsapp', existing); p.log.info('Run "lettabot server" to see the QR code and complete pairing.'); return { enabled: true, selfChat: isSelfChat, dmPolicy, allowedUsers, ...groupSettings, }; } 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' + 'Recommended: Link as secondary device (signal-cli link -n "LettaBot")\n' + 'This keeps your phone\'s Signal app working normally.\n\n' + 'Requires signal-cli registered or linked with your phone number.', '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'); } } const groupSettings = await promptGroupSettings('signal', existing); return { enabled: true, phone: phone || undefined, selfChat: isSelfChat, dmPolicy, allowedUsers, ...groupSettings, }; } /** 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]; }