feat: add ergonomic channel management CLI (#188)

* 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 <channel>` - Add with focused setup
- `lettabot channels remove <channel>` - Remove/disable
- `lettabot channels enable/disable <channel>` - 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 <noreply@letta.com>

* 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 <noreply@letta.com>

* clean up

---------

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Ari Webb
2026-02-06 10:58:09 -08:00
committed by GitHub
parent b1e1b5693c
commit 04f58e72c8
6 changed files with 748 additions and 301 deletions

View File

@@ -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';

421
src/channels/setup.ts Normal file
View File

@@ -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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
const setupFunctions: Record<ChannelId, (existing?: any) => Promise<any>> = {
telegram: setupTelegram,
slack: setupSlack,
discord: setupDiscord,
whatsapp: setupWhatsApp,
signal: setupSignal,
};
return setupFunctions[id];
}

View File

@@ -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 <ch> Add a channel (telegram, slack, discord, whatsapp, signal)
channels remove <ch> 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 <command>\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;

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
const channelIds = CHANNELS.map(c => c.id);
if (!channelId) {
console.error('Usage: lettabot channels remove <channel>');
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<void> {
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;
}
}

View File

@@ -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');

View File

@@ -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<string, string>): Pr
}
async function stepChannels(config: OnboardConfig, env: Record<string, string>): Promise<void> {
// 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<string, string>):
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<string, string>):
});
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);
}
}