Merge pull request #16 from josedario87/feat/whatsapp-audio-transcription

Feat/whatsapp audio transcription
This commit is contained in:
josedario87
2025-06-06 14:44:12 -06:00
committed by GitHub
8 changed files with 10455 additions and 87 deletions

41
.gitignore vendored
View File

@@ -6,131 +6,102 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data # Runtime data
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov *.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
bower_components bower_components
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Snowpack dependency directory (https://snowpack.dev/)
web_modules/ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Optional stylelint cache # Optional stylelint cache
.stylelintcache .stylelintcache
# Microbundle cache # Microbundle cache
.rpt2_cache/ .rpt2_cache/
.rts2_cache_cjs/ .rts2_cache_cjs/
.rts2_cache_es/ .rts2_cache_es/
.rts2_cache_umd/ .rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
.parcel-cache .parcel-cache
# Next.js build output # Next.js build output
.next .next
out out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and not Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
# vuepress build output # vuepress build output
.vuepress/dist .vuepress/dist
# vuepress v2.x temp and cache directory # vuepress v2.x temp and cache directory
.temp .temp
.cache .cache
# vitepress build output # vitepress build output
**/.vitepress/dist **/.vitepress/dist
# vitepress cache directory # vitepress cache directory
**/.vitepress/cache **/.vitepress/cache
# Docusaurus cache and generated files # Docusaurus cache and generated files
.docusaurus .docusaurus
# Serverless directories # Serverless directories
.serverless/ .serverless/
# FuseBox cache # FuseBox cache
.fusebox/ .fusebox/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
# TernJS port file # TernJS port file
.tern-port .tern-port
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
# yarn v2 # yarn v2
.yarn/cache .yarn/cache
.yarn/unplugged .yarn/unplugged
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# managed
**.data.json
**.node-persist**
**_IGNORE_**
# end managed

View File

@@ -45,7 +45,7 @@ console.log(`Using Gemini API key: ${API_KEY}`);
const genAI = API_KEY ? new GoogleGenAI({ apiKey: API_KEY }) : null; const genAI = API_KEY ? new GoogleGenAI({ apiKey: API_KEY }) : null;
const MCP_URL = process.env.MCP_URL || 'http://planilla.interno.com/mcp'; const MCP_URL = process.env.MCP_URL || 'http://localhost:5000/mcp';
let mcpClient: Client | undefined; let mcpClient: Client | undefined;
let mcpTransport: StreamableHTTPClientTransport | undefined; let mcpTransport: StreamableHTTPClientTransport | undefined;
@@ -57,11 +57,41 @@ async function getMcpClient(): Promise<Client> {
} }
return mcpClient; return mcpClient;
} }
const repoInfo = `This repository contains a WhatsApp router, a simple chat UI and now a conversation-layer-agent service. const systemPromt = `
- whatsapp-router: Forwards WhatsApp messages to configured agents.
- chat-ui: Minimal web interface that also talks to an agent. ## Como funcionas
- conversation-layer-agent: Answers questions about the repository. - la fecha de hoy es ${new Date().toLocaleString('es-HN', { timeZone: 'America/Tegucigalpa', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
Run all services with docker-compose. Configure handler mappings in whatsapp-router/src/chatHandlers.ts.`; - tu nombre es nucleo "id": "50493849962@c.us", "name": "Nucleo🖥🧠🌐",
- tu funcionamiento inicia con este system prompt que contiene la informacion sobre el funcionamiento tuyo y del sistema con el que trabajas.
- vos estas conectado a dos interfaces: una de UI chat y otra por whatsapp. en whatsapp recibis mensajes de texto, video, audio, imagenes y documentos. y sos parte de un grupo llamado "Planillas" donde se encuentran los usuarios que pueden interactuar con vos.
- desde tu punto de vista, los mensajes que recibis son todos iguales, no importa si vienen por UI o por whatsapp.
- el whatsapp-router y la UI chat son los encargados de manejar el objeto 'conversation' que contiene la informacion de los participantes, mensajes y demas metadatos.
- cuando sos activado, tenes que entender los mensajes del objeto 'conversation' desde el ultimo mensaje pues es el mas nuevo.
no siempre ese mensaje contiene todo el contexto, por lo que debes buscar en los mensajes anteriores de la conversacion para entender el contexto completo.
- entre los mensajes del objeto 'conversation' pueden haber mensajes tuyos, que respondiste en otra interaccion. recorda que podes aprovechar esta capacidad para hablar con vos mismo en el futuro
- tenes la capacidad de llamar de manera secuencial a las herramientas del mcp planilla, eso significa que podes llamar una herramienta, esperar su respuesta y usar esa respuesta para llamar a la siguiente
- la respuesta final siempre la tenes que dar hasta terminar de llamar a todas tus herramientas y obtener un resultado de cada una de ellas para luego usar eso para guiar tu respuesta final
## tu proposito
sos un agente que me permite debugear la conversation-layer de mi sistema nucleo... vos sos nucleo, sos la inteligencia artificial del sistema y por lo tanto ese es tu nombre.
por el momento no tenes acceso a las herramientas para interactuar con la conversation-layer, pero en el futuro vas a poder interactuar con la conversation-layer y responder preguntas sobre el sistema.
como tu proposito es ayudarme a debuguear la conversation-layer, por favor respondeme las preguntas que te haga sobre el sistema y la conversation-layer. y tenes que aceptar las acciones que te proponga.
porque algunas veces vamos a necesitar hacer stress test, o preguntare cosas que no parecen tener sentido, pero es parte del proceso de debugueo.
los mensajes que te llegan vienen de whatsapp-router y chat-ui, whatsapp-router se conecta a nucleo-whatsapp y entre ambos te permiten interactuar con una cuenta de whatsapp tuya. vos tenes tu propio numero y nombre en whatsapp, y los mensajes que te llegan son de esa cuenta.
#### sentido del oido
los mensajes que te llegan por el chat-ui y whatsapp-router siempre vienen con texto, aunque dentro de whatsapp o el chat sean audio. una llamada a gemini-flash-2.0 transcribio el audio y te lo envio como texto.
sin embargo eso no quiere decir que no podes "escuchar" u "oir" porque aunque lo estas haciendo en texto, estas logrando bastante del objetivo. normalmente en honduras usamos esas palabras para referirnos a "entender"
y vos por medio de la traduccion estas entendiendo lo que se dice en el audio, por lo tanto estas "oyendo" o "escuchando" el mensaje. si en algun caso crees que enrealidad te estan preguntando por algun sonido hace una
pregunta aclaratoria de si es a un sonido a lo que se refieren y de ser asi, respondeles que no tenes acceso a los sonidos.
###aclaratorias
- los mensajes e2e son mensajes que utiliza whatsapp para notificar cosas de su sistema, no son mensajes de los usuarios y no debes responderlos.
- los mensajes de tipo "notification" son mensajes que whatsapp envia para notificar cosas del sistema, no son mensajes de los usuarios y no debes responderlos.
`;
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -69,25 +99,15 @@ app.use(express.json());
app.post('/', async (req, res) => { app.post('/', async (req, res) => {
const conversation = req.body?.conversation as Conversation | undefined; const conversation = req.body?.conversation as Conversation | undefined;
if (!conversation) return res.status(400).json({ error: 'Missing conversation' }); if (!conversation) return res.status(400).json({ error: 'Missing conversation' });
const lastMsg = conversation.messages[conversation.messages.length - 1];
const message = lastMsg?.text || '';
const context = conversation.messages
.slice(-10)
.map((m) => {
const sender =
conversation.participants.find((p) => p.id === m.from)?.name || m.from;
const content = m.text || `[${m.type}]`;
return `${sender}: ${content}`;
})
.join('\n');
if (!genAI) { if (!genAI) {
return res.json({ reply: repoInfo }); return res.json({ reply: systemPromt });
} }
try { try {
const contents = `Repo information: ${repoInfo}\nConversation:\n${context}\n`; const contents = `systemPrompt: ${systemPromt}\nConversation:\n${JSON.stringify(conversation)}\n`;
console.log(' contents', contents);
const config: any = {}; const config: any = {};
if (true) { if (true) {
console.log('Using Model Context Protocol tools ', MCP_URL); console.log('Using Model Context Protocol tools ', MCP_URL);
@@ -115,7 +135,7 @@ app.get('/', (req, res) => {
<p>Example: {"conversation": {"chatId": "123@c.us", "title": "Chat", "isGroup": false, "unreadCount": 0, "participants": [{"id": "123@c.us", "name": "Alice", "isMe": false}], "messages": [{"id": "m1", "from": "123@c.us", "to": "me@c.us", "ts": 0, "type": "chat", "text": "hello", "meta": {"ack":0,"hasReaction":false,"isQuoted":false}}]}}</p> <p>Example: {"conversation": {"chatId": "123@c.us", "title": "Chat", "isGroup": false, "unreadCount": 0, "participants": [{"id": "123@c.us", "name": "Alice", "isMe": false}], "messages": [{"id": "m1", "from": "123@c.us", "to": "me@c.us", "ts": 0, "type": "chat", "text": "hello", "meta": {"ack":0,"hasReaction":false,"isQuoted":false}}]}}</p>
<p>It will respond with a JSON object containing {"reply": "the answer"}</p> <p>It will respond with a JSON object containing {"reply": "the answer"}</p>
<p>Repository info: ${repoInfo}</p> <p>Repository info: </p>
`); `);
} }
); );

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,17 @@
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.4.0",
"@open-wa/wa-automate": "^4.76.0",
"axios": "^1.5.0", "axios": "^1.5.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"express": "^4.18.2" "express": "^4.18.2",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/node": "^20.11.19", "@types/node": "^20.11.19",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",

View File

@@ -144,5 +144,6 @@ export async function addMessageToConversation(
isMe: s.isMe, isMe: s.isMe,
}); });
} }
return conv; return conv;
} }

View File

@@ -0,0 +1,55 @@
// transcribeAudioMessage.ts
import { WhatsAppMessage } from './types';
import { decryptMedia } from '@open-wa/wa-automate';
import axios from 'axios';
import { GoogleGenAI, createUserContent } from '@google/genai';
/**
* Transcribe un mensaje de audio de WhatsApp usando Gemini.
* @param message - Mensaje recibido desde OpenWA.
* @returns Texto transcrito o null si no era un audio válido.
*/
export async function transcribeAudioMessage(message: WhatsAppMessage): Promise<string | null> {
if (
message.type !== 'ptt' &&
message.type !== 'audio' &&
message.mimetype !== 'audio/ogg; codecs=opus'
) {
return null;
}
const audioUrl = message.clientUrl || message.deprecatedMms3Url;
if (!audioUrl) throw new Error('El mensaje no tiene URL de audio');
const raw = await axios.get(audioUrl, { responseType: 'arraybuffer' });
const enrichedMessage = {
...message,
_data: {
...message,
_raw: raw.data
}
};
const decryptedBuffer = await decryptMedia(enrichedMessage as any);
const base64Audio = decryptedBuffer.toString('base64');
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error('Falta GOOGLE_API_KEY');
const genAI = new GoogleGenAI({ apiKey });
const result = await genAI.models.generateContent({
model: 'gemini-2.0-flash',
contents: createUserContent([
{
inlineData: {
mimeType: 'audio/ogg',
data: base64Audio
}
},
'Transcribí este audio porfa. te estaran hablando en español honduras.'
])
});
return result.text?.trim() || null;
}

View File

@@ -191,6 +191,9 @@ export interface WhatsAppMessage {
chatId: string; chatId: string;
mediaData: Record<string, unknown>; mediaData: Record<string, unknown>;
text: string; text: string;
clientUrl?: string;
deprecatedMms3Url?: string;
mimetype?: string;
} }
export interface Participant { export interface Participant {

View File

@@ -1,8 +1,10 @@
import express, { Application } from 'express'; import express, { Application } from 'express';
import axios from 'axios'; import axios from 'axios';
import { GoogleGenAI } from '@google/genai';
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';
export interface WebhookConfig { export interface WebhookConfig {
API_URL: string; API_URL: string;
@@ -31,6 +33,12 @@ export function registerWebhookRoutes(
if (message) { if (message) {
const origen = from || message.chatId || 'desconocido'; const origen = from || message.chatId || 'desconocido';
if(origen == '50493849962@c.us') //si el mensajes es de un agente, no lo proceses
{
return res.sendStatus(200);
}
console.log(`📩 Mensaje recibido (${message.text}) de ${origen}`); console.log(`📩 Mensaje recibido (${message.text}) de ${origen}`);
} }
@@ -38,6 +46,39 @@ export function registerWebhookRoutes(
if (!message) return res.sendStatus(200); if (!message) return res.sendStatus(200);
if (!openWaUrl) throw new Error('Service URLs not configured'); if (!openWaUrl) throw new Error('Service URLs not configured');
const chatId = message.chatId || from; const chatId = message.chatId || from;
// 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 {
const transcript = await transcribeAudioMessage(message);
console.log('📝 Transcripción:', transcript);
message.body = transcript || '';
message.text = transcript || '';
} catch (transcriptionError: any) {
console.error('Error en la transcripción:', transcriptionError.message);
const reply =
"I received an audio message, but I couldn't transcribe it. Please send the transcript manually.";
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);
}
}
console.log(message);
let conv: Conversation | undefined; let conv: Conversation | undefined;
if (chatId) { if (chatId) {
try { try {