Refactor: Modularize whatsapp-router interactions
This commit introduces a significant refactoring to the whatsapp-router service to improve modularity and extend its capabilities for interacting with OpenWA (nucleo-whatsapp).
Key changes include:
1. **Message Processing Logic**:
* Created a new `messageProcessor.ts` module.
* Moved message mapping (`mapWhatsAppMessageToMsg`), message addition to conversations (`processIncomingMessageForConversation`), and audio transcription handling (`handleAudioMessageTranscription`) into this new module.
* Updated `store/conversation.ts` and `webhook.ts` to utilize `messageProcessor.ts`, streamlining their responsibilities.
2. **OpenWA Client (`nucleoClient.ts`)**:
* Introduced `nucleoClient.ts`, a dedicated module for encapsulating API calls to OpenWA.
* Implemented functions for various OpenWA commands:
* Sending text, image, and file messages.
* Fetching chat and contact information.
* Creating groups.
* Managing the blocklist (get, block, unblock).
* Listing all chats and fetching messages for a specific chat.
* Includes error handling and basic typing for API responses.
3. **New API Endpoints (`routes/nucleoActions.ts`)**:
* Created `nucleoActions.ts` to expose the functionalities of `nucleoClient.ts` via a new set of HTTP API endpoints.
* Endpoints cover all implemented client functions, with request validation and robust error handling.
* These routes are grouped under the `/nucleo` base path.
4. **Route Registration**:
* Registered the new `/nucleo` routes in the main `index.ts` file.
5. **Conversation Store and Routes Review**:
* Reviewed and confirmed that `store/conversation.ts` and `routes/conversations.ts` are correctly integrated with the new `messageProcessor.ts` and remain focused on their core responsibilities.
This refactoring enhances the structure of `whatsapp-router`, making it easier to maintain and extend. It also provides a comprehensive set of API endpoints for more granular control over OpenWA functionalities.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
waitForGateway,
|
waitForGateway,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
} from './webhook';
|
} from './webhook';
|
||||||
|
import nucleoActionsRouter from './routes/nucleoActions'; // New import
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -51,6 +52,9 @@ const config: WebhookConfig = {
|
|||||||
registerConversationRoutes(app, openWaUrl);
|
registerConversationRoutes(app, openWaUrl);
|
||||||
registerWebhookRoutes(app, config, openWaUrl, agentUrl);
|
registerWebhookRoutes(app, config, openWaUrl, agentUrl);
|
||||||
|
|
||||||
|
// Register new nucleoActions routes
|
||||||
|
app.use('/nucleo', nucleoActionsRouter); // New line
|
||||||
|
|
||||||
app.listen(port, async () => {
|
app.listen(port, async () => {
|
||||||
console.log(`WhatsApp router listening on ${port}`);
|
console.log(`WhatsApp router listening on ${port}`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
94
whatsapp-router/src/messageProcessor.ts
Normal file
94
whatsapp-router/src/messageProcessor.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { WhatsAppMessage, Msg, Conversation } from "../types";
|
||||||
|
import { transcribeAudioMessage } from "../transcribeAudioMessage";
|
||||||
|
import axios from 'axios'; // Needed for sending error messages
|
||||||
|
|
||||||
|
export const mapWhatsAppMessageToMsg = (m: WhatsAppMessage): Msg => {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
ts: (m as any).timestamp || (m as any).t,
|
||||||
|
type: ((m as any).type as any) || 'chat',
|
||||||
|
text: (m as any).text || (m as any).caption || (m as any).body,
|
||||||
|
mediaUrl: (m as any).cloudUrl || (m as any).clientUrl,
|
||||||
|
mentions: ((m as any).mentionedJidList as any) || [],
|
||||||
|
meta: {
|
||||||
|
ack: (m as any).ack || 0,
|
||||||
|
hasReaction: (m as any).hasReaction || false,
|
||||||
|
isQuoted: !!(m as any).quotedMsg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// processIncomingMessageForConversation incorporates logic from former addMessageToConversation
|
||||||
|
export const processIncomingMessageForConversation = (
|
||||||
|
conversation: Conversation,
|
||||||
|
message: WhatsAppMessage
|
||||||
|
): void => {
|
||||||
|
const mappedMsg = mapWhatsAppMessageToMsg(message);
|
||||||
|
|
||||||
|
// Avoid duplicates if multiple webhook events deliver the same message
|
||||||
|
if (!conversation.messages.some((m) => m.id === mappedMsg.id)) {
|
||||||
|
conversation.messages.push(mappedMsg);
|
||||||
|
if (conversation.messages.length > 20) {
|
||||||
|
conversation.messages.shift(); // Keep only the last 20 messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new participants if necessary
|
||||||
|
const sender = message.sender;
|
||||||
|
if (sender && !conversation.participants.some((p) => p.id === sender.id)) {
|
||||||
|
conversation.participants.push({
|
||||||
|
id: sender.id,
|
||||||
|
name: sender.pushname || sender.name || '',
|
||||||
|
isMe: sender.isMe,
|
||||||
|
// isAdmin property is not available here, it's part of group metadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The conversation object is modified directly
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAudioMessageTranscription = async (
|
||||||
|
message: WhatsAppMessage,
|
||||||
|
openWaUrl: string // Needed to send a reply if transcription fails
|
||||||
|
): Promise<WhatsAppMessage> => {
|
||||||
|
if (
|
||||||
|
message.type === 'ptt' &&
|
||||||
|
message.mimetype === 'audio/ogg; codecs=opus'
|
||||||
|
) {
|
||||||
|
const audioUrl = message.clientUrl || message.deprecatedMms3Url;
|
||||||
|
if (!audioUrl) {
|
||||||
|
console.error('[MessageProcessor] No audio URL found for PTT message');
|
||||||
|
// Not sending a message back here as webhook.ts will continue processing
|
||||||
|
// and potentially send a generic error or a handler-specific message.
|
||||||
|
// Alternatively, we could throw an error to be caught by the webhook.
|
||||||
|
return message; // Return original message
|
||||||
|
}
|
||||||
|
console.log('[MessageProcessor] 🎤 Audio message detected', audioUrl);
|
||||||
|
try {
|
||||||
|
const transcript = await transcribeAudioMessage(message);
|
||||||
|
console.log('[MessageProcessor] 📝 Transcription:', transcript);
|
||||||
|
message.body = transcript || ''; // Update message body with transcript
|
||||||
|
message.text = transcript || ''; // Also update text property
|
||||||
|
} catch (transcriptionError: any) {
|
||||||
|
console.error('[MessageProcessor] Error in transcription:', transcriptionError.message);
|
||||||
|
const reply =
|
||||||
|
"I received an audio message, but I couldn't transcribe it. Please send the transcript manually.";
|
||||||
|
// Need from/chatId to send reply
|
||||||
|
const chatId = message.chatId || message.from;
|
||||||
|
if (chatId && openWaUrl) {
|
||||||
|
try {
|
||||||
|
await axios.post(`${openWaUrl}/sendText`, { args: { to: chatId, content: reply } });
|
||||||
|
} catch (axiosError) {
|
||||||
|
console.error('[MessageProcessor] Failed to send transcription error message via Axios:', axiosError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We might want to throw an error here to stop further processing in webhook.ts
|
||||||
|
// For now, returning the original message, but webhook should check if body is empty.
|
||||||
|
// Or, perhaps, it's better to let webhook decide to stop processing.
|
||||||
|
// Let's re-throw to allow webhook to catch and decide.
|
||||||
|
throw transcriptionError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message; // Return original or modified message
|
||||||
|
};
|
||||||
313
whatsapp-router/src/nucleoClient.ts
Normal file
313
whatsapp-router/src/nucleoClient.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Chat, Contact, WhatsAppMessage } from './types'; // Corrected to WhatsAppMessage
|
||||||
|
|
||||||
|
// Placeholder for a generic OpenWA API response structure.
|
||||||
|
// This can be refined later if specific response types are identified.
|
||||||
|
export interface OpenWAResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
response?: T; // Often the actual data is nested here
|
||||||
|
data?: T; // Sometimes it might be here
|
||||||
|
message?: string;
|
||||||
|
// Other common fields can be added if observed from OpenWA responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a text message via OpenWA.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param to The recipient's chat ID (e.g., 'xxxxxxxxxxx@c.us').
|
||||||
|
* @param content The text message content.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function sendTextMessage(
|
||||||
|
openWaUrl: string,
|
||||||
|
to: string,
|
||||||
|
content: string
|
||||||
|
): Promise<OpenWAResponse> { // Using OpenWAResponse<any> for now
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/sendText`, {
|
||||||
|
args: { to, content },
|
||||||
|
});
|
||||||
|
// The actual data might be in response.data.response or response.data
|
||||||
|
// This needs to be consistent with how OpenWA structures its responses.
|
||||||
|
// Prioritizing response.data.response if it exists.
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error sending text message to ${to}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
// Log more detailed error from OpenWA if available
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/sendText): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/sendText): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an image message via OpenWA.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param to The recipient's chat ID.
|
||||||
|
* @param path URL or local path of the image OpenWA can access.
|
||||||
|
* @param caption Optional caption for the image.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function sendImageMessage(
|
||||||
|
openWaUrl: string,
|
||||||
|
to: string,
|
||||||
|
path: string, // Assuming 'path' is how OpenWA refers to the image URL/path
|
||||||
|
caption?: string
|
||||||
|
): Promise<OpenWAResponse> {
|
||||||
|
try {
|
||||||
|
const args: { to: string; path: string; caption?: string } = { to, path };
|
||||||
|
if (caption) {
|
||||||
|
args.caption = caption;
|
||||||
|
}
|
||||||
|
const response = await axios.post(`${openWaUrl}/sendImage`, { args });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error sending image message to ${to}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/sendImage): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/sendImage): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a file/document message via OpenWA.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param to The recipient's chat ID.
|
||||||
|
* @param path URL or local path of the file OpenWA can access.
|
||||||
|
* @param filename Optional name for the file.
|
||||||
|
* @param caption Optional caption for the file.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function sendFileMessage(
|
||||||
|
openWaUrl: string,
|
||||||
|
to: string,
|
||||||
|
path: string, // Assuming 'path' is how OpenWA refers to the file URL/path
|
||||||
|
filename?: string,
|
||||||
|
caption?: string
|
||||||
|
): Promise<OpenWAResponse> {
|
||||||
|
try {
|
||||||
|
const args: { to: string; path: string; filename?: string; caption?: string } = { to, path };
|
||||||
|
if (filename) {
|
||||||
|
args.filename = filename;
|
||||||
|
}
|
||||||
|
if (caption) {
|
||||||
|
args.caption = caption;
|
||||||
|
}
|
||||||
|
const response = await axios.post(`${openWaUrl}/sendFile`, { args });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error sending file message to ${to}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/sendFile): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/sendFile): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves chat details by chat ID.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param chatId The ID of the chat to retrieve.
|
||||||
|
* @returns A promise that resolves to the API response containing chat details.
|
||||||
|
*/
|
||||||
|
export async function getChatById(
|
||||||
|
openWaUrl: string,
|
||||||
|
chatId: string
|
||||||
|
): Promise<OpenWAResponse<Chat>> { // Assuming Chat type from types.ts
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/getChatById`, { args: { chatId } });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error retrieving chat ${chatId}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/getChatById): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/getChatById): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new group.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param groupName The name of the group to create.
|
||||||
|
* @param contactIds An array of contact IDs to add to the group.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function createGroup(
|
||||||
|
openWaUrl: string,
|
||||||
|
groupName: string,
|
||||||
|
contactIds: string[]
|
||||||
|
): Promise<OpenWAResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/createGroup`, {
|
||||||
|
args: { groupName, contactIds },
|
||||||
|
});
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error creating group "${groupName}":`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/createGroup): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/createGroup): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves contact details by contact ID.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param contactId The ID of the contact to retrieve.
|
||||||
|
* @returns A promise that resolves to the API response containing contact details.
|
||||||
|
*/
|
||||||
|
export async function getContact(
|
||||||
|
openWaUrl: string,
|
||||||
|
contactId: string
|
||||||
|
): Promise<OpenWAResponse<Contact>> { // Assuming Contact type from types.ts
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/getContact`, { args: { contactId } });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error retrieving contact ${contactId}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/getContact): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/getContact): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the blocklist.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @returns A promise that resolves to the API response containing the blocklist.
|
||||||
|
*/
|
||||||
|
export async function getBlocklist(
|
||||||
|
openWaUrl: string
|
||||||
|
): Promise<OpenWAResponse<string[]>> { // Assuming blocklist is an array of contact IDs
|
||||||
|
try {
|
||||||
|
// Some GET operations might be POST in OpenWA, or might not need args
|
||||||
|
const response = await axios.post(`${openWaUrl}/getBlocklist`);
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error retrieving blocklist:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/getBlocklist): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/getBlocklist): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks a contact.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param contactId The ID of the contact to block.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function blockContact(
|
||||||
|
openWaUrl: string,
|
||||||
|
contactId: string
|
||||||
|
): Promise<OpenWAResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/blockContact`, { args: { contactId } });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error blocking contact ${contactId}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/blockContact): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/blockContact): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unblocks a contact.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param contactId The ID of the contact to unblock.
|
||||||
|
* @returns A promise that resolves to the API response.
|
||||||
|
*/
|
||||||
|
export async function unblockContact(
|
||||||
|
openWaUrl: string,
|
||||||
|
contactId: string
|
||||||
|
): Promise<OpenWAResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${openWaUrl}/unblockContact`, { args: { contactId } });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error unblocking contact ${contactId}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/unblockContact): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/unblockContact): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all chats.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @returns A promise that resolves to the API response containing all chats.
|
||||||
|
*/
|
||||||
|
export async function getAllChats(
|
||||||
|
openWaUrl: string
|
||||||
|
): Promise<OpenWAResponse<Chat[]>> { // Assuming an array of Chat objects
|
||||||
|
try {
|
||||||
|
// This might be a GET request or a POST without args, depending on OpenWA
|
||||||
|
const response = await axios.post(`${openWaUrl}/getAllChats`);
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error retrieving all chats:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/getAllChats): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/getAllChats): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves messages for a specific chat.
|
||||||
|
* Maps to OpenWA's /loadAndGetAllMessagesInChat endpoint.
|
||||||
|
* @param openWaUrl The base URL of the OpenWA instance.
|
||||||
|
* @param chatId The ID of the chat.
|
||||||
|
* @param limit Optional limit for the number of messages.
|
||||||
|
* @param includeMe Optional flag to include messages sent by oneself.
|
||||||
|
* @param includeNotifications Optional flag to include notification messages.
|
||||||
|
* @returns A promise that resolves to the API response containing messages.
|
||||||
|
*/
|
||||||
|
export async function getChatMessages(
|
||||||
|
openWaUrl: string,
|
||||||
|
chatId: string,
|
||||||
|
limit?: number,
|
||||||
|
includeMe?: boolean,
|
||||||
|
includeNotifications?: boolean
|
||||||
|
): Promise<OpenWAResponse<WhatsAppMessage[]>> { // Corrected to WhatsAppMessage[]
|
||||||
|
try {
|
||||||
|
const args: {
|
||||||
|
chatId: string;
|
||||||
|
limit?: number;
|
||||||
|
includeMe?: boolean;
|
||||||
|
includeNotifications?: boolean;
|
||||||
|
} = { chatId };
|
||||||
|
if (limit !== undefined) args.limit = limit;
|
||||||
|
if (includeMe !== undefined) args.includeMe = includeMe;
|
||||||
|
if (includeNotifications !== undefined) args.includeNotifications = includeNotifications;
|
||||||
|
|
||||||
|
const response = await axios.post(`${openWaUrl}/loadAndGetAllMessagesInChat`, { args });
|
||||||
|
return response.data?.response || response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[nucleoClient] Error retrieving messages for chat ${chatId}:`, error.message);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
console.error('[nucleoClient] Axios error details:', error.response.data);
|
||||||
|
throw new Error(`nucleoClient API error (${openWaUrl}/loadAndGetAllMessagesInChat): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||||
|
}
|
||||||
|
throw new Error(`nucleoClient error (${openWaUrl}/loadAndGetAllMessagesInChat): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
whatsapp-router/src/routes/nucleoActions.ts
Normal file
263
whatsapp-router/src/routes/nucleoActions.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import express, { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import * as nucleoClient from '../nucleoClient';
|
||||||
|
// Assuming OPEN_WA_URL is set in the environment.
|
||||||
|
// For local development, dotenv would typically be used in the main app entry point (e.g., index.ts)
|
||||||
|
// require('dotenv').config(); // Potentially, but better if handled by the main application loader
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Retrieve OpenWA URL from environment variables
|
||||||
|
// This is done once when the module is loaded.
|
||||||
|
const openWaUrl = process.env.OPEN_WA_URL;
|
||||||
|
|
||||||
|
// Middleware to check if openWaUrl is configured
|
||||||
|
// This runs for every request to this router
|
||||||
|
router.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!openWaUrl) {
|
||||||
|
console.error('[routes/nucleoActions] Service OPEN_WA_URL not configured');
|
||||||
|
return res.status(500).json({ error: 'Service OPEN_WA_URL not configured. Please set the environment variable.' });
|
||||||
|
}
|
||||||
|
// Pass openWaUrl to subsequent handlers via res.locals if preferred,
|
||||||
|
// or they can access the `openWaUrl` constant from the module scope.
|
||||||
|
// For simplicity, handlers will use the module-scoped `openWaUrl`.
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route implementations
|
||||||
|
|
||||||
|
// POST /send-text
|
||||||
|
router.post('/send-text', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { to, content } = req.body;
|
||||||
|
if (!to || !content) {
|
||||||
|
return res.status(400).json({ error: 'Missing "to" or "content" in request body' });
|
||||||
|
}
|
||||||
|
// openWaUrl is checked by middleware and available in module scope
|
||||||
|
const result = await nucleoClient.sendTextMessage(openWaUrl!, to, content);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /send-text:', error.message);
|
||||||
|
// Check if the error message is already a JSON string from nucleoClient's error handling
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
// If not, send the plain error message
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to send text message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /send-image
|
||||||
|
router.post('/send-image', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { to, path, caption } = req.body;
|
||||||
|
if (!to || !path) {
|
||||||
|
return res.status(400).json({ error: 'Missing "to" or "path" in request body' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.sendImageMessage(openWaUrl!, to, path, caption);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /send-image:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to send image message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /send-file
|
||||||
|
router.post('/send-file', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { to, path, filename, caption } = req.body;
|
||||||
|
if (!to || !path) {
|
||||||
|
return res.status(400).json({ error: 'Missing "to" or "path" in request body' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.sendFileMessage(openWaUrl!, to, path, filename, caption);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /send-file:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to send file message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /chats/:chatId
|
||||||
|
router.get('/chats/:chatId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { chatId } = req.params;
|
||||||
|
if (!chatId) {
|
||||||
|
return res.status(400).json({ error: 'Missing "chatId" in request params' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.getChatById(openWaUrl!, chatId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[routes/nucleoActions] Error in /chats/${req.params.chatId}:`, error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to retrieve chat' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /groups
|
||||||
|
router.post('/groups', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupName, contactIds } = req.body;
|
||||||
|
if (!groupName || !contactIds || !Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Missing "groupName" or "contactIds" (must be a non-empty array) in request body' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.createGroup(openWaUrl!, groupName, contactIds);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /groups:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to create group' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /contacts/:contactId
|
||||||
|
router.get('/contacts/:contactId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { contactId } = req.params;
|
||||||
|
if (!contactId) {
|
||||||
|
return res.status(400).json({ error: 'Missing "contactId" in request params' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.getContact(openWaUrl!, contactId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[routes/nucleoActions] Error in /contacts/${req.params.contactId}:`, error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to retrieve contact' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /blocklist
|
||||||
|
router.get('/blocklist', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await nucleoClient.getBlocklist(openWaUrl!);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /blocklist:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to retrieve blocklist' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /blocklist/block
|
||||||
|
router.post('/blocklist/block', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { contactId } = req.body;
|
||||||
|
if (!contactId) {
|
||||||
|
return res.status(400).json({ error: 'Missing "contactId" in request body' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.blockContact(openWaUrl!, contactId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /blocklist/block:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to block contact' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /blocklist/unblock
|
||||||
|
router.post('/blocklist/unblock', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { contactId } = req.body;
|
||||||
|
if (!contactId) {
|
||||||
|
return res.status(400).json({ error: 'Missing "contactId" in request body' });
|
||||||
|
}
|
||||||
|
const result = await nucleoClient.unblockContact(openWaUrl!, contactId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /blocklist/unblock:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to unblock contact' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /chats
|
||||||
|
router.get('/chats', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await nucleoClient.getAllChats(openWaUrl!);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[routes/nucleoActions] Error in /chats:', error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to retrieve all chats' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /chats/:chatId/messages
|
||||||
|
router.get('/chats/:chatId/messages', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { chatId } = req.params;
|
||||||
|
if (!chatId) {
|
||||||
|
return res.status(400).json({ error: 'Missing "chatId" in request params' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, includeMe, includeNotifications } = req.query;
|
||||||
|
|
||||||
|
let numLimit: number | undefined = undefined;
|
||||||
|
if (limit) {
|
||||||
|
numLimit = parseInt(limit as string, 10);
|
||||||
|
if (isNaN(numLimit)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid "limit" query parameter, must be a number.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boolIncludeMe: boolean | undefined = includeMe ? (includeMe as string).toLowerCase() === 'true' : undefined;
|
||||||
|
const boolIncludeNotifications: boolean | undefined = includeNotifications ? (includeNotifications as string).toLowerCase() === 'true' : undefined;
|
||||||
|
|
||||||
|
const result = await nucleoClient.getChatMessages(
|
||||||
|
openWaUrl!,
|
||||||
|
chatId,
|
||||||
|
numLimit,
|
||||||
|
boolIncludeMe,
|
||||||
|
boolIncludeNotifications
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[routes/nucleoActions] Error in /chats/${req.params.chatId}/messages:`, error.message);
|
||||||
|
try {
|
||||||
|
const parsedError = JSON.parse(error.message.substring(error.message.indexOf('{')));
|
||||||
|
return res.status(parsedError.status || 500).json({ error: parsedError });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to retrieve chat messages' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { WhatsAppMessage, Conversation, Msg, Participant } from '../types';
|
import { WhatsAppMessage, Conversation, Msg, Participant } from '../types';
|
||||||
|
import { mapWhatsAppMessageToMsg, processIncomingMessageForConversation } from '../messageProcessor';
|
||||||
|
|
||||||
const conversations = new Map<string, Conversation>();
|
const conversations = new Map<string, Conversation>();
|
||||||
|
|
||||||
@@ -19,24 +20,6 @@ async function loadMessages(
|
|||||||
return msgs;
|
return msgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapMessage(m: WhatsAppMessage): Msg {
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
from: m.from,
|
|
||||||
to: m.to,
|
|
||||||
ts: (m as any).timestamp || (m as any).t,
|
|
||||||
type: ((m as any).type as any) || 'chat',
|
|
||||||
text: (m as any).text || (m as any).caption || (m as any).body,
|
|
||||||
mediaUrl: (m as any).cloudUrl || (m as any).clientUrl,
|
|
||||||
mentions: ((m as any).mentionedJidList as any) || [],
|
|
||||||
meta: {
|
|
||||||
ack: (m as any).ack || 0,
|
|
||||||
hasReaction: (m as any).hasReaction || false,
|
|
||||||
isQuoted: !!(m as any).quotedMsg,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConversation(
|
export async function getConversation(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
openWaUrl: string
|
openWaUrl: string
|
||||||
@@ -101,7 +84,7 @@ export async function buildConversation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages: Msg[] = rawMessages.slice(-20).map(mapMessage);
|
const messages: Msg[] = rawMessages.slice(-20).map(mapWhatsAppMessageToMsg);
|
||||||
messages.sort((a, b) => a.ts - b.ts);
|
messages.sort((a, b) => a.ts - b.ts);
|
||||||
|
|
||||||
const conv: Conversation = {
|
const conv: Conversation = {
|
||||||
@@ -130,20 +113,13 @@ export async function addMessageToConversation(
|
|||||||
): Promise<Conversation> {
|
): Promise<Conversation> {
|
||||||
console.log(`[conversationStore] Adding message to ${chatId}`);
|
console.log(`[conversationStore] Adding message to ${chatId}`);
|
||||||
const conv = await getConversation(chatId, openWaUrl);
|
const conv = await getConversation(chatId, openWaUrl);
|
||||||
const mapped = mapMessage(msg);
|
|
||||||
// avoid duplicates if multiple webhook events deliver the same message
|
// Delegate message processing to the new function
|
||||||
if (!conv.messages.some((m) => m.id === mapped.id)) {
|
// processIncomingMessageForConversation modifies `conv` directly
|
||||||
conv.messages.push(mapped);
|
processIncomingMessageForConversation(conv, msg);
|
||||||
if (conv.messages.length > 20) conv.messages.shift();
|
|
||||||
}
|
// Ensure the conversation is updated in the map (though it's by reference as conv is an object)
|
||||||
const s = msg.sender;
|
conversations.set(chatId, conv);
|
||||||
if (s && !conv.participants.some((p) => p.id === s.id)) {
|
|
||||||
conv.participants.push({
|
|
||||||
id: s.id,
|
|
||||||
name: s.pushname || s.name || '',
|
|
||||||
isMe: s.isMe,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return conv;
|
return conv;
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import express, { Application } from 'express';
|
import express, { Application } from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { GoogleGenAI } from '@google/genai';
|
// import { GoogleGenAI } from '@google/genai'; // Unused after transcription logic move
|
||||||
import { getHandler } from './chatHandlers';
|
import { getHandler } from './chatHandlers';
|
||||||
import { addMessageToConversation } from './store/conversation';
|
import { addMessageToConversation } from './store/conversation';
|
||||||
import { WhatsAppMessage, Conversation } from './types';
|
import { WhatsAppMessage, Conversation } from './types';
|
||||||
import { transcribeAudioMessage } from './transcribeAudioMessage';
|
// import { transcribeAudioMessage } from './transcribeAudioMessage'; // Moved to messageProcessor
|
||||||
|
import { handleAudioMessageTranscription } from '../messageProcessor'; // Added import
|
||||||
|
|
||||||
export interface WebhookConfig {
|
export interface WebhookConfig {
|
||||||
API_URL: string;
|
API_URL: string;
|
||||||
@@ -48,37 +49,17 @@ export function registerWebhookRoutes(
|
|||||||
const chatId = message.chatId || from;
|
const chatId = message.chatId || from;
|
||||||
|
|
||||||
// Audio message handling
|
// Audio message handling
|
||||||
// console.log(message);
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.type === 'ptt' &&
|
|
||||||
message.mimetype === 'audio/ogg; codecs=opus'
|
|
||||||
) {
|
|
||||||
const audioUrl = message.clientUrl || message.deprecatedMms3Url;
|
|
||||||
if (!audioUrl) {
|
|
||||||
console.error('No audio URL found for PTT message');
|
|
||||||
// Potentially send a message to user or just skip? For now, skip.
|
|
||||||
return res.sendStatus(200);
|
|
||||||
}
|
|
||||||
console.log('🎤 Mensaje de audio detectado', audioUrl);
|
|
||||||
try {
|
try {
|
||||||
const transcript = await transcribeAudioMessage(message);
|
// message object will be updated by reference if transcription occurs
|
||||||
console.log('📝 Transcripción:', transcript);
|
await handleAudioMessageTranscription(message, openWaUrl);
|
||||||
message.body = transcript || '';
|
|
||||||
message.text = transcript || '';
|
|
||||||
|
|
||||||
|
|
||||||
} catch (transcriptionError: any) {
|
} catch (transcriptionError: any) {
|
||||||
console.error('Error en la transcripción:', transcriptionError.message);
|
// Log the error already handled by handleAudioMessageTranscription (which also sends a message to user)
|
||||||
const reply =
|
console.error('[Webhook] Transcription failed, stopping further processing for this message.', transcriptionError.message);
|
||||||
"I received an audio message, but I couldn't transcribe it. Please send the transcript manually.";
|
// Stop processing this message as transcription failed and user has been notified by the processor.
|
||||||
await axios.post(`${openWaUrl}/sendText`, { args: { to: from, content: reply } });
|
|
||||||
// Stop processing this message as transcription failed and user has been notified.
|
|
||||||
return res.sendStatus(200);
|
return res.sendStatus(200);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(message);
|
// console.log(message); // For debugging, check if message.body is updated
|
||||||
let conv: Conversation | undefined;
|
let conv: Conversation | undefined;
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user