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:
21
.env.example
21
.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=*
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
82
src/api/auth.ts
Normal 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
245
src/api/multipart.ts
Normal 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
207
src/api/server.ts
Normal 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
33
src/api/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
33
src/main.ts
33
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
|
||||
|
||||
Reference in New Issue
Block a user