Files
lettabot/src/setup/slack-wizard.ts
cthomas 1d66f42dad feat: add non-interactive onboarding and SKILL.md (#45)
* feat: add non-interactive onboarding and SKILL.md

Add agent-friendly setup flow:
- lettabot onboard --non-interactive flag
- Reads all config from environment variables
- SKILL.md documents env-based setup for agents
- Supports all channels (Telegram, Slack, Discord, WhatsApp, Signal)
- No prompts - ideal for coding agents automating setup

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix: address non-interactive setup issues

- Add SLACK_APP_NAME for customizable app name (defaults to LETTA_AGENT_NAME or LettaBot)
- Clarify WhatsApp requires WHATSAPP_ENABLED and WHATSAPP_SELF_CHAT to be explicit
- Document all 5 channels supported (Telegram, Slack, Discord, WhatsApp, Signal)
- Fix WhatsApp selfChat default to be explicit false

* docs: recommend non-interactive setup as primary method

Update README per review feedback to show env-based setup first.
This is simpler for most users and ideal for automation.

* docs: rewrite setup to be AI-first per feedback

Make recommended setup AI-focused:
- Show prompt to paste into AI coding assistants
- AI handles clone/install/config autonomously
- Manual wizard becomes Option 2 for human users

---------

Co-authored-by: Letta <noreply@letta.com>
2026-01-30 16:14:29 -08:00

413 lines
11 KiB
TypeScript

/**
* Interactive Slack Setup Wizard
*
* Guides users through the full Slack app configuration process
* with browser automation and validation at each step.
*/
import * as p from '@clack/prompts';
interface SlackWizardResult {
appToken: string;
botToken: string;
allowedUsers?: string[];
}
// Shared validators (exported for use in onboard.ts manual flow)
export function validateAppToken(val: string): string | undefined {
if (!val) return 'App Token is required';
if (!val.startsWith('xapp-')) return 'App Token should start with "xapp-"';
if (val.length < 20) return 'Token appears too short';
}
export function validateBotToken(val: string): string | undefined {
if (!val) return 'Bot Token is required';
if (!val.startsWith('xoxb-')) return 'Bot Token should start with "xoxb-"';
if (val.length < 20) return 'Token appears too short';
}
function validateSlackUserId(val: string): string | undefined {
if (!val) return undefined; // Optional
const ids = val.split(',').map(s => s.trim());
for (const id of ids) {
if (!/^U[A-Z0-9]{8,}$/i.test(id)) {
return `Invalid Slack user ID: ${id} (should start with U)`;
}
}
}
/**
* Run the interactive Slack setup wizard
*/
export async function runSlackWizard(existingConfig?: {
appToken?: string;
botToken?: string;
allowedUsers?: string[];
}): Promise<SlackWizardResult | null> {
p.intro('🔧 Slack Setup Wizard');
p.note(
'This wizard creates a Slack app using a pre-configured manifest.\n' +
'All permissions and settings are configured automatically!\n\n' +
'Total time: ~2 minutes\n' +
'Press Ctrl+C to cancel anytime',
'Overview'
);
// Step 1: Create Slack App from Manifest (scopes, events, Socket Mode all pre-configured)
const createdApp = await stepCreateApp();
if (!createdApp) return null;
// Step 2: Install to Workspace + Get Bot Token
const botToken = await stepInstallApp(existingConfig?.botToken);
if (!botToken) return null;
// Step 3: Enable Socket Mode + Get App Token
const appToken = await stepEnableSocketMode(existingConfig?.appToken);
if (!appToken) return null;
// Validate tokens
await validateSlackTokens(appToken, botToken);
// Access control
const allowedUsers = await stepAccessControl(existingConfig?.allowedUsers);
p.outro('✅ Slack setup complete!');
return {
appToken,
botToken,
allowedUsers,
};
}
async function stepCreateApp(): Promise<boolean> {
p.log.step('Step 1/3: Create Slack App from Manifest');
// Inline manifest for Socket Mode configuration
const appName = process.env.SLACK_APP_NAME || process.env.LETTA_AGENT_NAME || 'LettaBot';
const manifest = `display_information:
name: ${appName}
description: AI assistant with Socket Mode for real-time conversations
background_color: "#2c2d30"
features:
bot_user:
display_name: ${appName}
always_online: false
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
- im:history
- im:read
- im:write
settings:
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
event_subscriptions:
bot_events:
- app_mention
- message.im`;
const manifestEncoded = encodeURIComponent(manifest);
const url = `https://api.slack.com/apps?new_app=1&manifest_yaml=${manifestEncoded}`;
p.note(
'Creates app with everything pre-configured:\n' +
' • Socket Mode enabled\n' +
' • 5 bot scopes (app_mentions:read, chat:write, im:*)\n' +
' • 2 event subscriptions (app_mention, message.im)\n\n' +
'Just review and click "Create"!',
'One-Click Setup'
);
const openBrowser = await p.confirm({
message: 'Open pre-configured app creation page?',
initialValue: true,
});
if (p.isCancel(openBrowser)) {
p.cancel('Setup cancelled');
return false;
}
if (openBrowser) {
try {
const open = (await import('open')).default;
await open(url, { wait: false });
p.log.success('Browser opened');
} catch {
p.log.warn('Could not open browser');
p.log.info('Copy this URL:');
console.log(url);
}
} else {
p.log.info('Copy this URL:');
console.log(url);
}
const completed = await p.confirm({
message: 'Clicked "Create"?',
initialValue: true,
});
if (p.isCancel(completed) || !completed) {
p.cancel('Slack setup skipped');
return false;
}
return true;
}
async function stepEnableSocketMode(existingToken?: string): Promise<string | null> {
p.log.step('Step 3/3: Get App-Level Token');
p.note(
'1. In the left sidebar, click "Socket Mode"\n' +
'2. Click "Generate Token"\n' +
' • Token Name: "socket-token"\n' +
' • Scopes: connections:write (pre-selected)\n' +
'3. Copy the token (xapp-...)',
'Instructions'
);
const appToken = await p.text({
message: 'Slack App Token (xapp-...)',
placeholder: 'xapp-1-A0ABKA5451U-...',
initialValue: existingToken || '',
validate: validateAppToken,
});
if (p.isCancel(appToken)) {
p.cancel('Setup cancelled');
return null;
}
return appToken;
}
async function stepConfigureScopes(): Promise<boolean> {
p.log.step('Step 3/6: Configure Bot Permissions');
p.note(
'1. In the left sidebar, go to "OAuth & Permissions"\n' +
'2. Scroll to "Scopes" → "Bot Token Scopes"\n' +
'3. Click "Add an OAuth Scope" for each:\n' +
' • app_mentions:read\n' +
' • chat:write\n' +
' • im:history\n' +
' • im:read\n' +
' • im:write',
'Instructions'
);
const completed = await p.confirm({
message: 'Enabled permissions?',
initialValue: true,
});
if (p.isCancel(completed) || !completed) {
p.cancel('Slack setup skipped');
return false;
}
return true;
}
async function stepConfigureEvents(): Promise<boolean> {
p.log.step('Step 4/6: Enable Event Subscriptions');
p.note(
'1. In the left sidebar, go to "Event Subscriptions"\n' +
'2. Toggle "Enable Events" → ON\n' +
'3. Scroll to "Subscribe to bot events"\n' +
'4. Click "Add Bot User Event" for each:\n' +
' • app_mention\n' +
' • message.im\n' +
'5. Click "Save Changes" at the bottom',
'Instructions'
);
const completed = await p.confirm({
message: 'Enabled subscriptions?',
initialValue: true,
});
if (p.isCancel(completed) || !completed) {
p.cancel('Slack setup skipped');
return false;
}
return true;
}
async function stepConfigureAppHome(): Promise<boolean> {
p.log.step('Step 5/6: Configure App Home');
p.note(
'1. Go to "App Home" in left sidebar\n' +
'2. Under "Show Tabs", toggle "Messages Tab" → ON\n' +
'3. Check "Allow users to send messages from the messages tab"',
'Instructions'
);
const completed = await p.confirm({
message: 'Enabled messaging?',
initialValue: true,
});
if (p.isCancel(completed) || !completed) {
p.log.warn('Skipping - DMs may not work without Messages Tab enabled');
// Continue anyway, just warn
}
return true;
}
async function stepInstallApp(existingToken?: string): Promise<string | null> {
p.log.step('Step 6/6: Install to Workspace');
p.note(
'1. Go to "Install App" in left sidebar\n' +
'2. Click "Install to Workspace"\n' +
'3. Click "Allow"\n' +
'4. Copy "Bot User OAuth Token" (xoxb-...)',
'Instructions'
);
const botToken = await p.text({
message: 'Slack Bot Token (xoxb-...)',
placeholder: 'xoxb-7365707142320-...',
initialValue: existingToken || '',
validate: validateBotToken,
});
if (p.isCancel(botToken)) {
p.cancel('Setup cancelled');
return null;
}
return botToken;
}
/**
* Validate Slack tokens via API
* Exported for use in both wizard and manual flows
*/
export async function validateSlackTokens(appToken: string, botToken: string): Promise<void> {
p.log.step('Validating Configuration');
const spinner = p.spinner();
spinner.start('Testing App Token...');
let appTokenValid = false;
let botTokenValid = false;
let botUsername = '';
let workspaceName = '';
// Test App Token with auth.test
try {
const response = await fetch('https://slack.com/api/auth.test', {
method: 'POST',
headers: {
'Authorization': `Bearer ${appToken}`,
'Content-Type': 'application/json',
},
});
const data = await response.json() as { ok: boolean; error?: string };
if (data.ok) {
spinner.stop('✓ App Token valid');
appTokenValid = true;
} else {
spinner.stop('✗ App Token validation failed');
p.log.error(`Error: ${data.error || 'Unknown error'}`);
p.log.warn('The token might not work. Double-check it in your Slack app settings.');
}
} catch (e) {
spinner.stop('Could not validate App Token (network error)');
p.log.warn('Skipping validation - will test when server starts');
}
spinner.start('Testing Bot Token...');
// Test Bot Token with auth.test
try {
const response = await fetch('https://slack.com/api/auth.test', {
method: 'POST',
headers: {
'Authorization': `Bearer ${botToken}`,
'Content-Type': 'application/json',
},
});
const data = await response.json() as { ok: boolean; error?: string; user?: string; team?: string };
if (data.ok) {
spinner.stop('✓ Bot Token valid');
botTokenValid = true;
botUsername = data.user || '';
workspaceName = data.team || '';
} else {
spinner.stop('✗ Bot Token validation failed');
p.log.error(`Error: ${data.error || 'Unknown error'}`);
p.log.warn('The token might not work. Double-check it in your Slack app settings.');
}
} catch (e) {
spinner.stop('Could not validate Bot Token (network error)');
p.log.warn('Skipping validation - will test when server starts');
}
// Show success summary if both tokens valid
if (appTokenValid && botTokenValid) {
p.note(
`Bot: @${botUsername}\n` +
`Workspace: ${workspaceName}\n` +
`Socket Mode: Enabled\n\n` +
`Your Slack bot is ready to receive messages!`,
'✓ Validation Successful'
);
}
}
/**
* Access control step - shared by wizard and manual flows
* Exported for reuse in onboard.ts
*/
export async function stepAccessControl(existingUsers?: string[]): Promise<string[] | undefined> {
const restrictSlack = await p.confirm({
message: 'Restrict to specific Slack users?',
initialValue: (existingUsers?.length || 0) > 0,
});
if (p.isCancel(restrictSlack)) return undefined;
if (restrictSlack) {
p.note(
'To find user IDs:\n' +
'1. Click on a user\'s profile in Slack\n' +
'2. Click ⋮ menu → "Copy member ID"\n' +
'3. IDs look like U01ABCD2EFG',
'Finding User IDs'
);
const users = await p.text({
message: 'Allowed Slack user IDs (comma-separated)',
placeholder: 'U01234567,U98765432',
initialValue: existingUsers?.join(',') || '',
validate: validateSlackUserId,
});
if (p.isCancel(users)) return undefined;
if (users) {
return users.split(',').map(s => s.trim()).filter(Boolean);
}
}
return undefined;
}