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:
@@ -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
421
src/channels/setup.ts
Normal 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];
|
||||
}
|
||||
16
src/cli.ts
16
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 <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;
|
||||
|
||||
|
||||
278
src/cli/channel-management.ts
Normal file
278
src/cli/channel-management.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
328
src/onboard.ts
328
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user