feat(whatsapp): add lettabot-message CLI support for text and files (#89)

Merged WhatsApp CLI support with HTTP API server.

Features:
- HTTP API server for CLI-to-bot communication across Docker boundaries
- WhatsApp text + file sending via `lettabot-message send --file photo.jpg`
- Unified multipart endpoint at /api/v1/messages
- Security: timing-safe auth, localhost binding, same-origin CORS
- Bad MAC error handling for WhatsApp encryption renegotiation

Written by Cameron ◯ Letta Code
This commit is contained in:
Parth Modi
2026-02-03 20:21:27 -05:00
committed by GitHub
parent 27402d86d8
commit d1d758739d
12 changed files with 809 additions and 68 deletions

View File

@@ -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=*

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ Thumbs.db
cron-log.jsonl
cron-jobs.json
lettabot-agent.json
lettabot-api.json
PERSONA.md
CLAUDE.md

82
src/api/auth.ts Normal file
View File

@@ -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)
);
}

245
src/api/multipart.ts Normal file
View File

@@ -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<MultipartResult> {
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<string, string>;
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<string, string> = {};
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
}

207
src/api/server.ts Normal file
View File

@@ -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<string> {
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));
}

33
src/api/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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
@@ -25,6 +25,7 @@ export interface ChannelAdapter {
// Capabilities (optional)
supportsEditing?(): boolean;
sendFile?(file: OutboundFile): Promise<{ messageId: string }>;
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;

View File

@@ -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,8 +622,11 @@ export class WhatsAppAdapter implements ChannelAdapter {
// Type safety: Socket must be available
if (!this.sock) continue;
// Extract message using module
const extracted = await extractInboundMessage(
// 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,
@@ -637,8 +637,24 @@ export class WhatsAppAdapter implements ChannelAdapter {
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);

View File

@@ -159,10 +159,73 @@ async function sendSignal(chatId: string, text: string): Promise<void> {
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<void> {
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<void> {
// 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<void> {
@@ -209,6 +272,8 @@ async function sendToChannel(channel: string, chatId: string, text: string): Pro
// Command handlers
async function sendCommand(args: string[]): Promise<void> {
let text = '';
let filePath = '';
let kind: 'image' | 'file' | undefined = undefined;
let channel = '';
let chatId = '';
@@ -220,7 +285,12 @@ async function sendCommand(args: string[]): Promise<void> {
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) {
@@ -229,9 +299,10 @@ async function sendCommand(args: string[]): Promise<void> {
}
}
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);
}
@@ -246,7 +317,7 @@ async function sendCommand(args: string[]): Promise<void> {
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);
}
@@ -257,7 +328,16 @@ async function sendCommand(args: string[]): Promise<void> {
}
try {
// 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<void> {
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 <text> Message text (required)
--channel, -c <name> Channel: telegram, slack, signal, discord (default: last used)
--text, -t <text> Message text (or caption when used with --file)
--file, -f <path> File path (optional, for file messages)
--image Treat file as image (vs document)
--channel, -c <name> Channel: telegram, slack, whatsapp, discord (default: last used)
--chat, --to <id> 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.
`);
}

View File

@@ -521,15 +521,46 @@ 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<void> {
async deliverToChannel(
channelId: string,
chatId: string,
options: {
text?: string;
filePath?: string;
kind?: 'image' | 'file';
}
): Promise<string | undefined> {
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');
}
/**

View File

@@ -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:

View File

@@ -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