Files
lettabot/src/main.ts
2026-01-28 23:10:47 -08:00

385 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void> {
// 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);
});