You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

232 lines
7.0 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/// <reference types="bun-types" />
import { CommandService } from './services/command';
import { ResponseQueue } from './services/response-queue';
import { WebhookManager } from './services/webhook-manager';
// Bun is available globally when running under Bun runtime
declare global {
var Bun: typeof import('bun');
}
export const REQUIRED_ENV = [
'EVOLUTION_API_URL',
'EVOLUTION_API_KEY',
'EVOLUTION_API_INSTANCE',
'WHATSAPP_COMMUNITY_ID',
'CHATBOT_PHONE_NUMBER',
'WEBHOOK_URL'
];
type WebhookPayload = {
event: string;
instance: string;
data: any;
// Other fields from Evolution API
};
export class WebhookServer {
private static getBaseUrl(request: Request): string {
const proto = request.headers.get('x-forwarded-proto') || 'http';
const host = request.headers.get('x-forwarded-host') || request.headers.get('host');
return `${proto}://${host}`;
}
static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint
const url = new URL(request.url);
if (url.pathname.endsWith('/health')) {
return new Response('OK', { status: 200 });
}
if (process.env.NODE_ENV !== 'test') {
console.log(' Incoming webhook request:')
// console.log(' Incoming webhook request:', {
// method: request.method,
// path: url.pathname,
// time: new Date().toISOString()
// });
}
// 1. Method validation
if (request.method !== 'POST') {
return new Response('🚫 Method not allowed', { status: 405 });
}
// 2. Content-Type validation
const contentType = request.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return new Response('🚫 Invalid content type', { status: 400 });
}
try {
// 3. Parse and validate payload
const payload = await request.json() as WebhookPayload;
if (!payload.event || !payload.instance) {
return new Response('🚫 Invalid payload', { status: 400 });
}
// 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set)
if ((process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) &&
payload.instance !== process.env.EVOLUTION_API_INSTANCE) {
return new Response('🚫 Invalid instance', { status: 403 });
}
// 5. Route events
// console.log(' Webhook event received:', {
// event: payload.event,
// instance: payload.instance,
// data: payload.data ? '[...]' : null
// });
switch (payload.event) {
case 'messages.upsert':
// console.log(' Handling message upsert:', {
// groupId: payload.data?.key?.remoteJid,
// message: payload.data?.message?.conversation
// });
await WebhookServer.handleMessageUpsert(payload.data);
break;
// Other events will be added later
}
return new Response('OK', { status: 200 });
} catch (error) {
console.error('❌ Error processing webhook:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
time: new Date().toISOString()
});
return new Response('Invalid request', { status: 400 });
}
}
static async handleMessageUpsert(data: any) {
if (!data?.key?.remoteJid || !data.message || !data.message.conversation) {
console.log('⚠️ Invalid message format - missing required fields');
return;
}
// Forward to command service only if:
// 1. It's a text message (has conversation field)
// 2. Starts with /tarea command
const messageText = data.message.conversation;
if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) {
// Parse command components
const commandParts = messageText.trim().split(/\s+/);
const command = commandParts[0].toLowerCase();
if (commandParts.length < 2) {
console.log('⚠️ Invalid /tarea command - missing action');
return;
}
const action = commandParts[1].toLowerCase();
let descriptionParts = [];
let dueDate = '';
// Process remaining parts
for (let i = 2; i < commandParts.length; i++) {
const part = commandParts[i];
// Check for date (YYYY-MM-DD)
if (/^\d{4}-\d{2}-\d{2}$/.test(part)) {
const date = new Date(part);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date >= today) {
dueDate = part;
} else {
descriptionParts.push(part); // Keep past dates in description
}
} else {
descriptionParts.push(part);
}
}
const description = descriptionParts.join(' ');
console.log('🔍 Detected /tarea command:', {
timestamp: new Date().toISOString(),
from: data.key.participant,
group: data.key.remoteJid,
rawMessage: messageText
});
const mentions = data.contextInfo?.mentionedJid || [];
console.log('✅ Successfully parsed command:', {
action,
description,
dueDate: dueDate || 'none',
mentionCount: mentions.length
});
const responses = await CommandService.handle({
sender: data.key.participant,
groupId: data.key.remoteJid,
message: messageText,
mentions
});
// Queue responses for sending
await ResponseQueue.add(responses);
}
}
static validateEnv() {
console.log(' Checking environment variables...');
console.log('EVOLUTION_API_URL:', process.env.EVOLUTION_API_URL ? '***' : 'MISSING');
console.log('EVOLUTION_API_INSTANCE:', process.env.EVOLUTION_API_INSTANCE || 'MISSING');
console.log('WEBHOOK_URL:', process.env.WEBHOOK_URL ? `${process.env.WEBHOOK_URL.substring(0, 20)}...` : 'MISSING');
const missing = REQUIRED_ENV.filter(v => !process.env[v]);
if (missing.length) {
console.error('❌ Missing required environment variables:');
missing.forEach(v => console.error(`- ${v}`));
console.error('Add these to your CapRover environment configuration');
process.exit(1);
}
if (process.env.CHATBOT_PHONE_NUMBER &&
!/^\d+$/.test(process.env.CHATBOT_PHONE_NUMBER)) {
console.error('❌ CHATBOT_PHONE_NUMBER must contain only digits');
process.exit(1);
}
}
static async start() {
this.validateEnv();
const PORT = process.env.PORT || '3007';
console.log('✅ Environment variables validated');
if (process.env.NODE_ENV !== 'test') {
try {
await WebhookManager.registerWebhook();
// Add small delay to allow webhook to propagate
await new Promise(resolve => setTimeout(resolve, 1000));
const isActive = await WebhookManager.verifyWebhook();
if (!isActive) {
console.error('❌ Webhook verification failed - retrying in 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
const isActiveRetry = await WebhookManager.verifyWebhook();
if (!isActiveRetry) {
console.error('❌ Webhook verification failed after retry');
process.exit(1);
}
}
} catch (error) {
console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const server = Bun.serve({
port: parseInt(PORT),
fetch: (request) => WebhookServer.handleRequest(request)
});
console.log(`Server running on port ${PORT}`);
return server;
}
}