576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
/**
|
|
* 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<ChannelId, string> = {
|
|
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<string, { mode: GroupMode }>;
|
|
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<string, any> = {};
|
|
|
|
// 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<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);
|
|
}
|
|
}
|
|
|
|
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<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) {
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
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<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');
|
|
}
|
|
}
|
|
|
|
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<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' +
|
|
'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<any> {
|
|
const setupFunctions: Record<ChannelId, (existing?: any) => Promise<any>> = {
|
|
telegram: setupTelegram,
|
|
slack: setupSlack,
|
|
discord: setupDiscord,
|
|
whatsapp: setupWhatsApp,
|
|
signal: setupSignal,
|
|
};
|
|
return setupFunctions[id];
|
|
}
|