diff --git a/.env.example b/.env.example index ddd19dd..6540c58 100644 --- a/.env.example +++ b/.env.example @@ -123,3 +123,24 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token # GMAIL_CLIENT_SECRET=your_client_secret # GMAIL_REFRESH_TOKEN=your_refresh_token # GMAIL_TELEGRAM_USER=123456789 + +# ============================================ +# API Server (for Docker/CLI integration) +# ============================================ +# API key for CLI authentication (auto-generated if not set) +# Check bot server logs on first run to see the generated key +# LETTABOT_API_KEY=your-secret-key-here + +# API server URL (for CLI when bot runs in Docker) +# LETTABOT_API_URL=http://localhost:8080 + +# API server port (default: 8080) +# PORT=8080 + +# API server bind address (default: 127.0.0.1 for security) +# Use 0.0.0.0 in Docker to expose on all interfaces +# API_HOST=127.0.0.1 + +# CORS allowed origin (default: same-origin only) +# Use '*' to allow all origins (not recommended for production) +# API_CORS_ORIGIN=* diff --git a/.gitignore b/.gitignore index 05b32a1..fcba737 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Thumbs.db cron-log.jsonl cron-jobs.json lettabot-agent.json +lettabot-api.json PERSONA.md CLAUDE.md diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..0562e94 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,82 @@ +/** + * API key management for LettaBot HTTP API + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { IncomingHttpHeaders } from 'http'; + +const API_KEY_FILE = 'lettabot-api.json'; + +interface ApiKeyStore { + apiKey: string; +} + +/** + * Generate a secure random API key (64 hex chars) + */ +export function generateApiKey(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Load API key from file or environment, or generate new one + */ +export function loadOrGenerateApiKey(): string { + // 1. Check environment variable first + if (process.env.LETTABOT_API_KEY) { + return process.env.LETTABOT_API_KEY; + } + + // 2. Try to load from file + const filePath = path.resolve(process.cwd(), API_KEY_FILE); + if (fs.existsSync(filePath)) { + try { + const data = fs.readFileSync(filePath, 'utf-8'); + const store: ApiKeyStore = JSON.parse(data); + if (store.apiKey && typeof store.apiKey === 'string') { + return store.apiKey; + } + } catch (error) { + console.warn(`[API] Failed to load API key from ${API_KEY_FILE}:`, error); + } + } + + // 3. Generate new key and save + const newKey = generateApiKey(); + saveApiKey(newKey); + return newKey; +} + +/** + * Save API key to file + */ +export function saveApiKey(key: string): void { + const filePath = path.resolve(process.cwd(), API_KEY_FILE); + const store: ApiKeyStore = { apiKey: key }; + + try { + fs.writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); + console.log(`[API] Key saved to ${API_KEY_FILE}`); + } catch (error) { + console.error(`[API] Failed to save API key to ${API_KEY_FILE}:`, error); + } +} + +/** + * Validate API key from request headers + */ +export function validateApiKey(headers: IncomingHttpHeaders, expectedKey: string): boolean { + const providedKey = headers['x-api-key']; + + if (!providedKey || typeof providedKey !== 'string') { + return false; + } + + // Use constant-time comparison to prevent timing attacks + return crypto.timingSafeEqual( + Buffer.from(providedKey), + Buffer.from(expectedKey) + ); +} diff --git a/src/api/multipart.ts b/src/api/multipart.ts new file mode 100644 index 0000000..8787ac5 --- /dev/null +++ b/src/api/multipart.ts @@ -0,0 +1,245 @@ +/** + * Lightweight multipart/form-data parser for file uploads + * Stream-based to avoid memory issues with large files + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import type { IncomingMessage } from 'http'; + +export interface MultipartFile { + fieldName: string; + filename: string; + mimeType: string; + tempPath: string; +} + +export interface MultipartFields { + [key: string]: string; +} + +export interface MultipartResult { + fields: MultipartFields; + files: MultipartFile[]; +} + +/** + * Parse multipart/form-data from HTTP request + * @param req - Incoming HTTP request + * @param maxFileSize - Maximum file size in bytes (default 50MB) + * @returns Parsed fields and files + */ +export async function parseMultipart( + req: IncomingMessage, + maxFileSize: number = 50 * 1024 * 1024 +): Promise { + const contentType = req.headers['content-type']; + if (!contentType || !contentType.includes('multipart/form-data')) { + throw new Error('Content-Type must be multipart/form-data'); + } + + // Extract boundary from content-type header + const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/); + if (!boundaryMatch) { + throw new Error('Missing boundary in multipart/form-data'); + } + const boundary = boundaryMatch[1] || boundaryMatch[2]; + const boundaryBuffer = Buffer.from(`--${boundary}`); + const endBoundaryBuffer = Buffer.from(`--${boundary}--`); + + const fields: MultipartFields = {}; + const files: MultipartFile[] = []; + + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + let currentPart: { + headers: Record; + fieldName?: string; + filename?: string; + mimeType?: string; + fileStream?: fs.WriteStream; + tempPath?: string; + fileSize: number; + isFile: boolean; + data: Buffer; + } | null = null; + + const cleanup = () => { + if (currentPart?.fileStream) { + currentPart.fileStream.close(); + } + // Clean up any partial files + files.forEach(file => { + try { + if (fs.existsSync(file.tempPath)) { + fs.unlinkSync(file.tempPath); + } + } catch {} + }); + }; + + req.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + // Process buffer for parts + let boundaryIndex: number; + while ((boundaryIndex = buffer.indexOf(boundaryBuffer)) !== -1) { + // Check if this is the end boundary + const isEndBoundary = buffer.indexOf(endBoundaryBuffer) === boundaryIndex; + + if (currentPart) { + // Save current part data (everything before boundary) + const partData = buffer.slice(0, boundaryIndex - 2); // -2 for \r\n before boundary + + if (currentPart.isFile && currentPart.fileStream) { + // Write to file + currentPart.fileStream.write(partData); + currentPart.fileSize += partData.length; + + if (currentPart.fileSize > maxFileSize) { + cleanup(); + reject(new Error(`File too large (max ${maxFileSize} bytes)`)); + return; + } + + // Close file stream + currentPart.fileStream.end(); + + if (currentPart.fieldName && currentPart.filename && currentPart.tempPath) { + files.push({ + fieldName: currentPart.fieldName, + filename: currentPart.filename, + mimeType: currentPart.mimeType || 'application/octet-stream', + tempPath: currentPart.tempPath, + }); + } + } else { + // Store as field + currentPart.data = Buffer.concat([currentPart.data, partData]); + if (currentPart.fieldName) { + fields[currentPart.fieldName] = currentPart.data.toString('utf-8'); + } + } + + currentPart = null; + } + + // Move buffer past boundary + if (isEndBoundary) { + // Finished parsing + resolve({ fields, files }); + return; + } + + buffer = buffer.slice(boundaryIndex + boundaryBuffer.length); + + // Parse headers for next part + const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n')); + if (headerEnd === -1) { + // Need more data + break; + } + + const headerSection = buffer.slice(0, headerEnd).toString('utf-8'); + const headers: Record = {}; + + headerSection.split('\r\n').forEach(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim().toLowerCase(); + const value = line.slice(colonIndex + 1).trim(); + headers[key] = value; + } + }); + + // Parse Content-Disposition header + const disposition = headers['content-disposition']; + if (!disposition) { + reject(new Error('Missing Content-Disposition header')); + return; + } + + const nameMatch = disposition.match(/name="([^"]+)"/); + const filenameMatch = disposition.match(/filename="([^"]+)"/); + const fieldName = nameMatch ? nameMatch[1] : undefined; + const filename = filenameMatch ? sanitizeFilename(filenameMatch[1]) : undefined; + const mimeType = headers['content-type'] || 'application/octet-stream'; + + currentPart = { + headers, + fieldName, + filename, + mimeType, + fileSize: 0, + isFile: !!filename, + data: Buffer.alloc(0), + }; + + // If this is a file, create temp file stream + if (currentPart.isFile && currentPart.filename) { + const tempPath = path.join( + os.tmpdir(), + `lettabot-upload-${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${currentPart.filename}` + ); + currentPart.tempPath = tempPath; + currentPart.fileStream = fs.createWriteStream(tempPath); + + currentPart.fileStream.on('error', (err) => { + cleanup(); + reject(err); + }); + } + + // Move buffer past headers + buffer = buffer.slice(headerEnd + 4); // +4 for \r\n\r\n + } + }); + + req.on('end', () => { + // If we have remaining data in current part, save it + if (currentPart) { + if (currentPart.isFile && currentPart.fileStream) { + currentPart.fileStream.write(buffer); + currentPart.fileStream.end(); + + if (currentPart.fieldName && currentPart.filename && currentPart.tempPath) { + files.push({ + fieldName: currentPart.fieldName, + filename: currentPart.filename, + mimeType: currentPart.mimeType || 'application/octet-stream', + tempPath: currentPart.tempPath, + }); + } + } else { + currentPart.data = Buffer.concat([currentPart.data, buffer]); + if (currentPart.fieldName) { + fields[currentPart.fieldName] = currentPart.data.toString('utf-8'); + } + } + } + + resolve({ fields, files }); + }); + + req.on('error', (err) => { + cleanup(); + reject(err); + }); + }); +} + +/** + * Sanitize filename to prevent path traversal and remove special characters + */ +function sanitizeFilename(filename: string): string { + // Remove path components + const basename = path.basename(filename); + + // Remove or replace special characters + return basename + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_{2,}/g, '_') + .slice(0, 255); // Limit length +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..443f3dc --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,207 @@ +/** + * HTTP API server for LettaBot + * Provides endpoints for CLI to send messages across Docker boundaries + */ + +import * as http from 'http'; +import * as fs from 'fs'; +import { validateApiKey } from './auth.js'; +import type { SendMessageRequest, SendMessageResponse, SendFileResponse } from './types.js'; +import { parseMultipart } from './multipart.js'; +import type { LettaBot } from '../core/bot.js'; +import type { ChannelId } from '../core/types.js'; + +const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal']; +const MAX_BODY_SIZE = 10 * 1024; // 10KB +const MAX_TEXT_LENGTH = 10000; // 10k chars +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + +interface ServerOptions { + port: number; + apiKey: string; + host?: string; // Bind address (default: 127.0.0.1 for security) + corsOrigin?: string; // CORS origin (default: same-origin only) +} + +/** + * Create and start the HTTP API server + */ +export function createApiServer(bot: LettaBot, options: ServerOptions): http.Server { + const server = http.createServer(async (req, res) => { + // Set CORS headers (configurable origin, defaults to same-origin for security) + const corsOrigin = options.corsOrigin || req.headers.origin || 'null'; + res.setHeader('Access-Control-Allow-Origin', corsOrigin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key'); + + // Handle OPTIONS preflight + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // Route: GET /health or GET / + if ((req.url === '/health' || req.url === '/') && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + // Route: POST /api/v1/messages (unified: supports both text and files) + if (req.url === '/api/v1/messages' && req.method === 'POST') { + try { + // Validate authentication + if (!validateApiKey(req.headers, options.apiKey)) { + sendError(res, 401, 'Unauthorized'); + return; + } + + const contentType = req.headers['content-type'] || ''; + + // Parse multipart/form-data (supports both text-only and file uploads) + if (!contentType.includes('multipart/form-data')) { + sendError(res, 400, 'Content-Type must be multipart/form-data'); + return; + } + + // Parse multipart data + const { fields, files } = await parseMultipart(req, MAX_FILE_SIZE); + + // Validate required fields + if (!fields.channel || !fields.chatId) { + sendError(res, 400, 'Missing required fields: channel, chatId'); + return; + } + + if (!VALID_CHANNELS.includes(fields.channel as ChannelId)) { + sendError(res, 400, `Invalid channel: ${fields.channel}`, 'channel'); + return; + } + + // Validate that either text or file is provided + if (!fields.text && files.length === 0) { + sendError(res, 400, 'Either text or file must be provided'); + return; + } + + const file = files.length > 0 ? files[0] : undefined; + + // Send via unified bot method + const messageId = await bot.deliverToChannel( + fields.channel as ChannelId, + fields.chatId, + { + text: fields.text, + filePath: file?.tempPath, + kind: fields.kind as 'image' | 'file' | undefined, + } + ); + + // Cleanup temp file if any + if (file) { + try { + fs.unlinkSync(file.tempPath); + } catch (err) { + console.warn('[API] Failed to cleanup temp file:', err); + } + } + + // Success response + const response: SendMessageResponse = { + success: true, + messageId, + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + } catch (error: any) { + console.error('[API] Error handling request:', error); + sendError(res, 500, error.message || 'Internal server error'); + } + return; + } + + // Route: 404 Not Found + sendError(res, 404, 'Not found'); + }); + + // Bind to localhost by default for security (prevents network exposure on bare metal) + // Use API_HOST=0.0.0.0 in Docker to expose on all interfaces + const host = options.host || '127.0.0.1'; + server.listen(options.port, host, () => { + console.log(`[API] Server listening on ${host}:${options.port}`); + }); + + return server; +} + +/** + * Read request body with size limit + */ +function readBody(req: http.IncomingMessage, maxSize: number): Promise { + return new Promise((resolve, reject) => { + let body = ''; + let size = 0; + + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxSize) { + reject(new Error(`Request body too large (max ${maxSize} bytes)`)); + return; + } + body += chunk.toString(); + }); + + req.on('end', () => { + resolve(body); + }); + + req.on('error', (error) => { + reject(error); + }); + }); +} + +/** + * Validate send message request + */ +function validateRequest(request: SendMessageRequest): { message: string; field?: string } | null { + if (!request.channel) { + return { message: 'Missing required field: channel', field: 'channel' }; + } + + if (!request.chatId) { + return { message: 'Missing required field: chatId', field: 'chatId' }; + } + + if (!request.text) { + return { message: 'Missing required field: text', field: 'text' }; + } + + if (!VALID_CHANNELS.includes(request.channel as ChannelId)) { + return { message: `Invalid channel: ${request.channel}`, field: 'channel' }; + } + + if (typeof request.text !== 'string') { + return { message: 'Field "text" must be a string', field: 'text' }; + } + + if (request.text.length > MAX_TEXT_LENGTH) { + return { message: `Text too long (max ${MAX_TEXT_LENGTH} chars)`, field: 'text' }; + } + + return null; +} + +/** + * Send error response + */ +function sendError(res: http.ServerResponse, status: number, message: string, field?: string): void { + const response: SendMessageResponse = { + success: false, + error: message, + field, + }; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..ed14cad --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,33 @@ +/** + * Request/response types for LettaBot HTTP API + */ + +export interface SendMessageRequest { + channel: string; + chatId: string; + text: string; + threadId?: string; +} + +export interface SendMessageResponse { + success: boolean; + messageId?: string; + error?: string; + field?: string; +} + +export interface SendFileRequest { + channel: string; + chatId: string; + filePath: string; // Temporary file path on server + caption?: string; + kind?: 'image' | 'file'; + threadId?: string; +} + +export interface SendFileResponse { + success: boolean; + messageId?: string; + error?: string; + field?: string; +} diff --git a/src/channels/types.ts b/src/channels/types.ts index 0c23c78..ebcc40e 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -4,7 +4,7 @@ * Each channel (Telegram, Slack, Discord, WhatsApp, Signal) implements this interface. */ -import type { ChannelId, InboundMessage, OutboundMessage } from '../core/types.js'; +import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile } from '../core/types.js'; /** * Channel adapter - implement this for each messaging platform @@ -22,9 +22,10 @@ export interface ChannelAdapter { sendMessage(msg: OutboundMessage): Promise<{ messageId: string }>; editMessage(chatId: string, messageId: string, text: string): Promise; sendTypingIndicator(chatId: string): Promise; - + // Capabilities (optional) supportsEditing?(): boolean; + sendFile?(file: OutboundFile): Promise<{ messageId: string }>; // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 87bc551..f8ee789 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -591,11 +591,8 @@ export class WhatsAppAdapter implements ChannelAdapter { continue; } - // Deduplicate using TTL cache + // Build dedupe key (but don't check yet - wait until after extraction succeeds) const dedupeKey = `whatsapp:${remoteJid}:${messageId}`; - if (this.dedupeCache.check(dedupeKey)) { - continue; // Duplicate message - skip - } // Detect self-chat const isSelfChat = isSelfChatMessage( @@ -625,20 +622,39 @@ export class WhatsAppAdapter implements ChannelAdapter { // Type safety: Socket must be available if (!this.sock) continue; - // Extract message using module - const extracted = await extractInboundMessage( - m, - this.sock, - this.groupMetaCache, - // Pass attachment config if enabled - this.attachmentsDir && this.downloadContentFromMessage ? { - downloadContentFromMessage: this.downloadContentFromMessage, - attachmentsDir: this.attachmentsDir, - attachmentsMaxBytes: this.attachmentsMaxBytes, - } : undefined - ); + // CRITICAL: Extract message BEFORE deduplication + // This allows failed messages (Bad MAC, decryption errors) to retry after session renegotiation + let extracted; + try { + extracted = await extractInboundMessage( + m, + this.sock, + this.groupMetaCache, + // Pass attachment config if enabled + this.attachmentsDir && this.downloadContentFromMessage ? { + downloadContentFromMessage: this.downloadContentFromMessage, + attachmentsDir: this.attachmentsDir, + attachmentsMaxBytes: this.attachmentsMaxBytes, + } : undefined + ); + } catch (err) { + // Extraction threw error (e.g., Bad MAC during session renegotiation) + // Skip without marking as seen → WhatsApp will retry after session fix + continue; + } - if (!extracted) continue; // No text or invalid message + if (!extracted) { + // Extraction returned null (no text, invalid format, or decryption failure) + // Skip without marking as seen → allows retry on next attempt + continue; + } + + // Deduplicate ONLY after successful extraction + // Why: If we dedupe first, failed messages get marked as "seen" and are lost forever + // With this order: Failed messages can retry after WhatsApp renegotiates the session + if (this.dedupeCache.check(dedupeKey)) { + continue; // Duplicate message - skip + } const { body, from, chatId, pushName, senderE164, chatType, isSelfChat: isExtractedSelfChat } = extracted; const userId = normalizePhoneForStorage(from); diff --git a/src/cli/message.ts b/src/cli/message.ts index 89d6010..9d39636 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -159,10 +159,73 @@ async function sendSignal(chatId: string, text: string): Promise { console.log(`✓ Sent to signal:${chatId}`); } +/** + * Send message or file via API (unified multipart endpoint) + */ +async function sendViaApi( + channel: string, + chatId: string, + options: { + text?: string; + filePath?: string; + kind?: 'image' | 'file'; + } +): Promise { + const apiUrl = process.env.LETTABOT_API_URL || 'http://localhost:8080'; + const apiKey = process.env.LETTABOT_API_KEY; + + if (!apiKey) { + throw new Error('LETTABOT_API_KEY not set. Check bot server logs for the key.'); + } + + // Check if file exists + if (options.filePath && !existsSync(options.filePath)) { + throw new Error(`File not found: ${options.filePath}`); + } + + // Everything uses multipart now (Option B) + const formData = new FormData(); + formData.append('channel', channel); + formData.append('chatId', chatId); + + if (options.text) { + formData.append('text', options.text); + } + + if (options.filePath) { + const fileContent = readFileSync(options.filePath); + const fileName = options.filePath.split('/').pop() || 'file'; + formData.append('file', new Blob([fileContent]), fileName); + } + + if (options.kind) { + formData.append('kind', options.kind); + } + + const response = await fetch(`${apiUrl}/api/v1/messages`, { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error (${response.status}): ${error}`); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Unknown error'); + } + + const type = options.filePath ? 'file' : 'message'; + console.log(`✓ Sent ${type} to ${channel}:${chatId}`); +} + async function sendWhatsApp(chatId: string, text: string): Promise { - // WhatsApp requires a running session, so we write to a queue file - // that the bot process picks up. For now, error out. - throw new Error('WhatsApp sending via CLI not yet supported (requires active session)'); + return sendViaApi('whatsapp', chatId, { text }); } async function sendDiscord(chatId: string, text: string): Promise { @@ -209,18 +272,25 @@ async function sendToChannel(channel: string, chatId: string, text: string): Pro // Command handlers async function sendCommand(args: string[]): Promise { let text = ''; + let filePath = ''; + let kind: 'image' | 'file' | undefined = undefined; let channel = ''; let chatId = ''; - + // Parse args for (let i = 0; i < args.length; i++) { const arg = args[i]; const next = args[i + 1]; - + if ((arg === '--text' || arg === '-t') && next) { text = next; i++; - } else if ((arg === '--channel' || arg === '-c') && next) { + } else if ((arg === '--file' || arg === '-f') && next) { + filePath = next; + i++; + } else if (arg === '--image') { + kind = 'image'; + } else if ((arg === '--channel' || arg === '-c' || arg === '-C') && next) { channel = next; i++; } else if ((arg === '--chat' || arg === '--to') && next) { @@ -228,13 +298,14 @@ async function sendCommand(args: string[]): Promise { i++; } } - - if (!text) { - console.error('Error: --text is required'); - console.error('Usage: lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456]'); + + // Check if text OR file provided + if (!text && !filePath) { + console.error('Error: --text or --file is required'); + console.error('Usage: lettabot-message send --text "..." OR --file path.pdf [--text "caption"]'); process.exit(1); } - + // Resolve defaults from last target if (!channel || !chatId) { const lastTarget = loadLastTarget(); @@ -243,21 +314,30 @@ async function sendCommand(args: string[]): Promise { chatId = chatId || lastTarget.chatId; } } - + if (!channel) { console.error('Error: --channel is required (no default available)'); - console.error('Specify: --channel telegram|slack|signal|discord'); + console.error('Specify: --channel telegram|slack|signal|discord|whatsapp'); process.exit(1); } - + if (!chatId) { console.error('Error: --chat is required (no default available)'); console.error('Specify: --chat '); process.exit(1); } - + try { - await sendToChannel(channel, chatId, text); + // Use API for WhatsApp (unified multipart endpoint) + if (channel === 'whatsapp') { + await sendViaApi(channel, chatId, { text, filePath, kind }); + } else if (filePath) { + // Other channels with files - not yet implemented via API + throw new Error(`File sending for ${channel} requires API (currently only WhatsApp supported via API)`); + } else { + // Other channels with text only - direct API calls + await sendToChannel(channel, chatId, text); + } } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); @@ -266,32 +346,44 @@ async function sendCommand(args: string[]): Promise { function showHelp(): void { console.log(` -lettabot-message - Send messages to channels +lettabot-message - Send messages or files to channels Commands: - send [options] Send a message + send [options] Send a message or file Send options: - --text, -t Message text (required) - --channel, -c Channel: telegram, slack, signal, discord (default: last used) + --text, -t Message text (or caption when used with --file) + --file, -f File path (optional, for file messages) + --image Treat file as image (vs document) + --channel, -c Channel: telegram, slack, whatsapp, discord (default: last used) --chat, --to Chat/conversation ID (default: last messaged) Examples: - # Send to last messaged user/channel + # Send text message lettabot-message send --text "Hello!" - # Send to specific Telegram chat - lettabot-message send --text "Hello!" --channel telegram --chat 123456789 + # Send file with caption/text + lettabot-message send --file screenshot.png --text "Check this out" + + # Send file without text + lettabot-message send --file photo.jpg --image + + # Send to specific WhatsApp chat + lettabot-message send --file report.pdf --text "Report attached" --channel whatsapp --chat "+1555@s.whatsapp.net" # Short form - lettabot-message send -t "Done!" -c telegram -to 123456789 + lettabot-message send -t "Done!" -f doc.pdf -c telegram Environment variables: TELEGRAM_BOT_TOKEN Required for Telegram SLACK_BOT_TOKEN Required for Slack DISCORD_BOT_TOKEN Required for Discord - SIGNAL_PHONE_NUMBER Required for Signal + SIGNAL_PHONE_NUMBER Required for Signal (text only, no files) + LETTABOT_API_KEY Required for WhatsApp (text and files) + LETTABOT_API_URL API server URL (default: http://localhost:8080) SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090) + +Note: WhatsApp uses the API server. Other channels use direct platform APIs. `); } diff --git a/src/core/bot.ts b/src/core/bot.ts index 6058fa7..b329873 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -521,17 +521,48 @@ export class LettaBot { } /** - * Deliver a message to a specific channel + * Deliver a message or file to a specific channel */ - async deliverToChannel(channelId: string, chatId: string, text: string): Promise { + async deliverToChannel( + channelId: string, + chatId: string, + options: { + text?: string; + filePath?: string; + kind?: 'image' | 'file'; + } + ): Promise { const adapter = this.channels.get(channelId); if (!adapter) { console.error(`Channel not found: ${channelId}`); - return; + throw new Error(`Channel not found: ${channelId}`); } - await adapter.sendMessage({ chatId, text }); + + // Send file if provided + if (options.filePath) { + if (typeof adapter.sendFile !== 'function') { + throw new Error(`Channel ${channelId} does not support file sending`); + } + + const result = await adapter.sendFile({ + chatId, + filePath: options.filePath, + caption: options.text, // text becomes caption for files + kind: options.kind, + }); + + return result.messageId; + } + + // Send text message + if (options.text) { + const result = await adapter.sendMessage({ chatId, text: options.text }); + return result.messageId; + } + + throw new Error('Either text or filePath must be provided'); } - + /** * Get bot status */ diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 47bb085..981e63b 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -15,7 +15,7 @@ You communicate through multiple channels and trigger types. Understanding when **RESPONSIVE MODE** (User Messages) - When a user sends you a message, you are in responsive mode - Your text responses are automatically delivered to the user -- You can also use \`lettabot-message\` CLI to send to OTHER channels +- You can use \`lettabot-message\` CLI to add files or send messages to OTHER channels - You can use \`lettabot-react\` CLI to add emoji reactions **SILENT MODE** (Heartbeats, Cron Jobs, Polling, Background Tasks) @@ -24,17 +24,26 @@ You communicate through multiple channels and trigger types. Understanding when - To contact the user, you MUST use the \`lettabot-message\` CLI via Bash: \`\`\`bash -# Send to the last user who messaged you (default) +# Send text to the last user who messaged you (default) lettabot-message send --text "Hello! I found something interesting." -# Send to a specific channel and chat +# Send file with caption +lettabot-message send --file /path/to/image.jpg --text "Check this out!" + +# Send file without text (treated as image) +lettabot-message send --file photo.png --image + +# Send to specific channel and chat lettabot-message send --text "Hello!" --channel telegram --chat 123456789 -# Add a reaction to the most recent message (uses last stored message ID) +# Add a reaction to the most recent message lettabot-react add --emoji :eyes: # Add a reaction to a specific message lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321 + +# Note: File sending supported on telegram, slack, whatsapp (via API) +# Signal does not support files or reactions \`\`\` The system will clearly indicate when you are in silent mode with a banner like: diff --git a/src/main.ts b/src/main.ts index 7600081..01a93bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,14 @@ * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ -import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync, readdirSync, promises as fs } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; +// API server imports +import { createApiServer } from './api/server.js'; +import { loadOrGenerateApiKey } from './api/auth.js'; + // Load YAML config and apply to process.env (overrides .env values) import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; import { isLettaCloudUrl } from './utils/server.js'; @@ -487,20 +490,20 @@ async function main() { // 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}`); + // Load/generate API key for CLI authentication + const apiKey = loadOrGenerateApiKey(); + console.log(`[API] Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); + + // Start API server (replaces health server, includes health checks) + // Provides endpoints for CLI to send messages across Docker boundaries + const apiPort = parseInt(process.env.PORT || '8080', 10); + const apiHost = process.env.API_HOST; // undefined = 127.0.0.1 (secure default) + const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only + const apiServer = createApiServer(bot, { + port: apiPort, + apiKey: apiKey, + host: apiHost, + corsOrigin: apiCorsOrigin, }); // Log status