/** * LettaBot - Multi-Channel AI Assistant * * Single agent, single conversation across all channels. * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ // Load .env first for backwards compatibility import 'dotenv/config'; import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn } from 'node:child_process'; // Load YAML config and apply to process.env (overrides .env values) import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; const yamlConfig = loadConfig(); console.log(`[Config] Loaded from ${resolveConfigPath()}`); console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); applyConfigToEnv(yamlConfig); // Sync BYOK providers on startup (async, don't block) syncProviders(yamlConfig).catch(err => console.error('[Config] Failed to sync providers:', err)); // Load agent ID from store and set as env var (SDK needs this) // Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json'); const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; if (existsSync(STORE_PATH)) { try { const store = JSON.parse(readFileSync(STORE_PATH, 'utf-8')); // Check for server mismatch if (store.agentId && store.baseUrl) { const storedUrl = store.baseUrl.replace(/\/$/, ''); const currentUrl = currentBaseUrl.replace(/\/$/, ''); if (storedUrl !== currentUrl) { console.warn(`\n⚠️ Server mismatch detected!`); console.warn(` Stored agent was created on: ${storedUrl}`); console.warn(` Current server: ${currentUrl}`); console.warn(` The agent ${store.agentId} may not exist on this server.`); console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); } } if (store.agentId) { process.env.LETTA_AGENT_ID = store.agentId; } } catch {} } // Allow LETTA_AGENT_ID env var to override (useful for local server testing) // This is already set if passed on command line // OAuth token refresh - check and refresh before loading SDK import { loadTokens, saveTokens, isTokenExpired, hasRefreshToken, getDeviceName } from './auth/tokens.js'; import { refreshAccessToken } from './auth/oauth.js'; async function refreshTokensIfNeeded(): Promise { // If env var is set, that takes precedence (no refresh needed) if (process.env.LETTA_API_KEY) { return; } // OAuth tokens only work with Letta Cloud - skip if using custom server const baseUrl = process.env.LETTA_BASE_URL; if (baseUrl && baseUrl !== 'https://api.letta.com') { return; } const tokens = loadTokens(); if (!tokens?.accessToken) { return; // No stored tokens } // Set access token to env var process.env.LETTA_API_KEY = tokens.accessToken; // Check if token needs refresh if (isTokenExpired(tokens) && hasRefreshToken(tokens)) { try { console.log('[OAuth] Refreshing access token...'); const newTokens = await refreshAccessToken( tokens.refreshToken!, tokens.deviceId, getDeviceName(), ); // Update stored tokens const now = Date.now(); saveTokens({ accessToken: newTokens.access_token, refreshToken: newTokens.refresh_token ?? tokens.refreshToken, tokenExpiresAt: now + newTokens.expires_in * 1000, deviceId: tokens.deviceId, deviceName: tokens.deviceName, }); // Update env var with new token process.env.LETTA_API_KEY = newTokens.access_token; console.log('[OAuth] Token refreshed successfully'); } catch (err) { console.error('[OAuth] Failed to refresh token:', err instanceof Error ? err.message : err); console.error('[OAuth] You may need to re-authenticate with `lettabot onboard`'); } } } // Run token refresh before importing SDK (which reads LETTA_API_KEY) await refreshTokensIfNeeded(); import { LettaBot } from './core/bot.js'; import { TelegramAdapter } from './channels/telegram.js'; import { SlackAdapter } from './channels/slack.js'; import { WhatsAppAdapter } from './channels/whatsapp.js'; import { SignalAdapter } from './channels/signal.js'; import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; import { agentExists } from './tools/letta-api.js'; import { installSkillsToWorkingDir } from './skills/loader.js'; // Check if setup is needed const ENV_PATH = resolve(process.cwd(), '.env'); if (!existsSync(ENV_PATH)) { console.log('\n No .env file found. Running setup wizard...\n'); const setupPath = new URL('./setup.ts', import.meta.url).pathname; spawn('npx', ['tsx', setupPath], { stdio: 'inherit', cwd: process.cwd() }); process.exit(0); } // Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890") function parseHeartbeatTarget(raw?: string): { channel: string; chatId: string } | undefined { if (!raw || !raw.includes(':')) return undefined; const [channel, chatId] = raw.split(':'); if (!channel || !chatId) return undefined; return { channel: channel.toLowerCase(), chatId }; } // Skills are installed to agent-scoped directory when agent is created (see core/bot.ts) // Configuration from environment const config = { workingDir: process.env.WORKING_DIR || '/tmp/lettabot', model: process.env.MODEL, // e.g., 'claude-sonnet-4-20250514' allowedTools: (process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search').split(','), // Channel configs telegram: { enabled: !!process.env.TELEGRAM_BOT_TOKEN, token: process.env.TELEGRAM_BOT_TOKEN || '', dmPolicy: (process.env.TELEGRAM_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').filter(Boolean).map(Number) || [], }, slack: { enabled: !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_APP_TOKEN, botToken: process.env.SLACK_BOT_TOKEN || '', appToken: process.env.SLACK_APP_TOKEN || '', allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').filter(Boolean) || [], }, whatsapp: { enabled: process.env.WHATSAPP_ENABLED === 'true', sessionPath: process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session', dmPolicy: (process.env.WHATSAPP_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').filter(Boolean) || [], selfChatMode: process.env.WHATSAPP_SELF_CHAT_MODE === 'true', }, signal: { enabled: !!process.env.SIGNAL_PHONE_NUMBER, phoneNumber: process.env.SIGNAL_PHONE_NUMBER || '', cliPath: process.env.SIGNAL_CLI_PATH || 'signal-cli', httpHost: process.env.SIGNAL_HTTP_HOST || '127.0.0.1', httpPort: parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10), dmPolicy: (process.env.SIGNAL_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').filter(Boolean) || [], selfChatMode: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', // Default true }, // Cron cronEnabled: process.env.CRON_ENABLED === 'true', // Heartbeat - simpler config heartbeat: { enabled: !!process.env.HEARTBEAT_INTERVAL_MIN, intervalMinutes: parseInt(process.env.HEARTBEAT_INTERVAL_MIN || '0', 10) || 30, prompt: process.env.HEARTBEAT_PROMPT, target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET), }, // Polling - system-level background checks polling: { enabled: !!process.env.GMAIL_ACCOUNT, // Enable if any poller is configured intervalMs: parseInt(process.env.POLLING_INTERVAL_MS || '60000', 10), // Default 1 minute gmail: { enabled: !!process.env.GMAIL_ACCOUNT, account: process.env.GMAIL_ACCOUNT || '', }, }, }; // Validate at least one channel is configured if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enabled && !config.signal.enabled) { console.error('\n Error: No channels configured.'); console.error(' Set TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, WHATSAPP_ENABLED=true, or SIGNAL_PHONE_NUMBER\n'); process.exit(1); } async function main() { console.log('Starting LettaBot...\n'); // Install feature-gated skills based on enabled features // Skills are NOT installed by default - only when their feature is enabled const skillsDir = resolve(config.workingDir, '.skills'); mkdirSync(skillsDir, { recursive: true }); installSkillsToWorkingDir(config.workingDir, { cronEnabled: config.cronEnabled, googleEnabled: config.polling.gmail.enabled, // Gmail polling uses gog skill }); const existingSkills = readdirSync(skillsDir).filter(f => !f.startsWith('.')); if (existingSkills.length > 0) { console.log(`[Skills] ${existingSkills.length} skill(s) available: ${existingSkills.join(', ')}`); } // Create bot const bot = new LettaBot({ workingDir: config.workingDir, model: config.model, agentName: process.env.AGENT_NAME || 'LettaBot', allowedTools: config.allowedTools, }); // Verify agent exists (clear stale ID if deleted) let initialStatus = bot.getStatus(); if (initialStatus.agentId) { const exists = await agentExists(initialStatus.agentId); if (!exists) { console.log(`[Agent] Stored agent ${initialStatus.agentId} not found - creating new agent...`); bot.reset(); initialStatus = bot.getStatus(); } } // Agent will be created on first user message (lazy initialization) if (!initialStatus.agentId) { console.log('[Agent] No agent found - will create on first message'); } // Register enabled channels if (config.telegram.enabled) { const telegram = new TelegramAdapter({ token: config.telegram.token, dmPolicy: config.telegram.dmPolicy, allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined, }); bot.registerChannel(telegram); } if (config.slack.enabled) { const slack = new SlackAdapter({ botToken: config.slack.botToken, appToken: config.slack.appToken, allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined, }); bot.registerChannel(slack); } if (config.whatsapp.enabled) { const whatsapp = new WhatsAppAdapter({ sessionPath: config.whatsapp.sessionPath, dmPolicy: config.whatsapp.dmPolicy, allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined, selfChatMode: config.whatsapp.selfChatMode, }); bot.registerChannel(whatsapp); } if (config.signal.enabled) { const signal = new SignalAdapter({ phoneNumber: config.signal.phoneNumber, cliPath: config.signal.cliPath, httpHost: config.signal.httpHost, httpPort: config.signal.httpPort, dmPolicy: config.signal.dmPolicy, allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined, selfChatMode: config.signal.selfChatMode, }); bot.registerChannel(signal); } // Start cron service if enabled let cronService: CronService | null = null; if (config.cronEnabled) { cronService = new CronService(bot, { storePath: `${config.workingDir}/cron-jobs.json`, }); await cronService.start(); } // Create heartbeat service (always available for /heartbeat command) const heartbeatService = new HeartbeatService(bot, { enabled: config.heartbeat.enabled, intervalMinutes: config.heartbeat.intervalMinutes, prompt: config.heartbeat.prompt, workingDir: config.workingDir, target: config.heartbeat.target, }); // Start auto-heartbeats only if interval is configured if (config.heartbeat.enabled) { heartbeatService.start(); } // Wire up /heartbeat command (always available) bot.onTriggerHeartbeat = () => heartbeatService.trigger(); // Start polling service if enabled (Gmail, etc.) let pollingService: PollingService | null = null; if (config.polling.enabled) { pollingService = new PollingService(bot, { intervalMs: config.polling.intervalMs, workingDir: config.workingDir, gmail: config.polling.gmail, }); pollingService.start(); } // Start all channels await bot.start(); // Start health check server (for Railway/Docker health checks) // Only exposes "ok" - no sensitive info const healthPort = parseInt(process.env.PORT || '8080', 10); const healthServer = createServer((req, res) => { if (req.url === '/health' || req.url === '/') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); } else { res.writeHead(404); res.end('Not found'); } }); healthServer.listen(healthPort, () => { console.log(`[Health] Listening on :${healthPort}`); }); // Log status const status = bot.getStatus(); console.log('\n================================='); console.log('LettaBot is running!'); console.log('================================='); console.log(`Agent ID: ${status.agentId || '(will be created on first message)'}`); console.log(`Channels: ${status.channels.join(', ')}`); console.log(`Cron: ${config.cronEnabled ? 'enabled' : 'disabled'}`); console.log(`Heartbeat: ${config.heartbeat.enabled ? `every ${config.heartbeat.intervalMinutes} min` : 'disabled'}`); console.log(`Polling: ${config.polling.enabled ? `every ${config.polling.intervalMs / 1000}s` : 'disabled'}`); if (config.polling.gmail.enabled) { console.log(` └─ Gmail: ${config.polling.gmail.account}`); } if (config.heartbeat.enabled) { console.log(`Heartbeat target: ${config.heartbeat.target ? `${config.heartbeat.target.channel}:${config.heartbeat.target.chatId}` : 'last messaged'}`); } console.log('=================================\n'); // Handle shutdown const shutdown = async () => { console.log('\nShutting down...'); heartbeatService?.stop(); cronService?.stop(); await bot.stop(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); } main().catch((e) => { console.error('Fatal error:', e); process.exit(1); });