From 7893a05f6311cb15f209b484d208ef6fa21bed9c Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 28 Jun 2026 08:33:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(media):=20transcri=C3=A7=C3=A3o=20de=20?= =?UTF-8?q?=C3=A1udio=20+=20descri=C3=A7=C3=A3o=20de=20imagem=20no=20motor?= =?UTF-8?q?=20(via=20Gemini)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move a transcrição para o motor (genérica p/ todos os tenants/satélites): - media-transcribe.ts: Gemini 2.0 Flash (áudio→texto, imagem→descrição) a partir do buffer já baixado; limite inline 18MB; fallback silencioso. - MessageHandler.processMedia: após salvar a mídia, transcreve fire-and-forget e grava em messages.transcription, emitindo 'message:transcription' p/ a UI. - schema: campo Message.transcription (coluna aplicada nos bancos). Co-Authored-By: Claude Opus 4.8 --- .../backend/prisma/schema.prisma | 1 + .../whatsapp/handlers/MessageHandler.ts | 17 ++++++ .../src/modules/whatsapp/media-transcribe.ts | 60 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/media-transcribe.ts diff --git a/newwhats.clube67.com/newwhats.local/backend/prisma/schema.prisma b/newwhats.clube67.com/newwhats.local/backend/prisma/schema.prisma index e5e4a63..9031360 100644 --- a/newwhats.clube67.com/newwhats.local/backend/prisma/schema.prisma +++ b/newwhats.clube67.com/newwhats.local/backend/prisma/schema.prisma @@ -229,6 +229,7 @@ model Message { type MessageType @default(TEXT) body String? caption String? + transcription String? // transcrição (áudio) ou descrição (imagem) via IA mediaUrl String? mediaPath String? mimeType String? diff --git a/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts index d4ee67e..10a0381 100644 --- a/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts +++ b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts @@ -37,6 +37,7 @@ import type { Server as SocketIOServer } from 'socket.io' import type { ChatbotService } from '../../../modules/chatbot/chatbot.service' import { hookBus } from '../../../core/hook-bus' import { invalidateChatListCache } from "../../chats/chat.cache" +import { transcribeOrDescribe } from '../media-transcribe' /** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ const MEDIA_DIR = path.resolve('./media') @@ -986,12 +987,28 @@ export class MessageHandler { this.persistSticker(msg, mediaUrl).catch(() => {}) } + // Transcrição (áudio) / descrição (imagem) via IA — fire-and-forget. + if (contentType === 'audioMessage' || contentType === 'imageMessage') { + this.transcribeMedia(savedMsgId, chatId, buffer as Buffer, originalMimetype, contentType).catch(() => {}) + } + logger.debug({ savedMsgId, filePath }, 'Mídia salva') } catch (err) { logger.error({ err, savedMsgId }, 'Falha ao baixar mídia') } } + /** Transcreve áudio / descreve imagem via Gemini e salva em messages.transcription. */ + private async transcribeMedia( + messageId: string, chatId: string, buffer: Buffer, mimetype: string | undefined, contentType: string, + ): Promise { + const text = await transcribeOrDescribe(buffer, mimetype, contentType) + if (!text) return + await prisma.message.update({ where: { id: messageId }, data: { transcription: text } }).catch(() => {}) + this.io.to(`chat:${chatId}`).emit('message:transcription', { messageId, transcription: text }) + logger.debug({ messageId, contentType }, '[media-transcribe] transcrição salva') + } + private async persistSticker(msg: WAMessage, wasabiPath: string): Promise { const stickerMsg = msg.message?.stickerMessage if (!stickerMsg) return diff --git a/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/media-transcribe.ts b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/media-transcribe.ts new file mode 100644 index 0000000..f78e327 --- /dev/null +++ b/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/media-transcribe.ts @@ -0,0 +1,60 @@ +/** + * media-transcribe.ts — transcreve áudio (PTT) e descreve imagens recebidas + * via WhatsApp usando Gemini Flash (multimodal nativo), no MOTOR. + * + * Genérico para todos os tenants (antes vivia só no satélite). Roda no servidor, + * a partir do buffer já baixado em processMedia — não exige operador na tela. + * Falha silenciosa (retorna null) → o fluxo de mídia segue normalmente. + */ +import { logger } from '../../config/logger' + +const GEMINI_MODEL = 'gemini-2.0-flash' +const GEMINI_API = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent` +const MAX_BYTES = 18 * 1024 * 1024 // limite inline prático do Gemini + +/** + * Retorna a transcrição (áudio) ou descrição (imagem), ou null se desabilitado/ + * falhar. `contentType` é o tipo do Baileys ('audioMessage' | 'imageMessage'). + */ +export async function transcribeOrDescribe( + buffer: Buffer, + mimetype: string | undefined, + contentType: string, +): Promise { + const apiKey = process.env.GEMINI_API_KEY + if (!apiKey) return null + if (!buffer || buffer.length === 0 || buffer.length > MAX_BYTES) return null + + const isAudio = contentType === 'audioMessage' + const isImage = contentType === 'imageMessage' + if (!isAudio && !isImage) return null + + const prompt = isAudio + ? 'Transcreva este áudio em português, fielmente. Responda APENAS a transcrição, sem comentários.' + : 'Descreva esta imagem em português de forma objetiva (1–2 frases). Responda APENAS a descrição.' + + try { + const res = await fetch(`${GEMINI_API}?key=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ + parts: [ + { text: prompt }, + { inline_data: { mime_type: mimetype || (isAudio ? 'audio/ogg' : 'image/jpeg'), data: buffer.toString('base64') } }, + ], + }], + }), + }) + if (!res.ok) { + logger.warn({ status: res.status, contentType }, '[media-transcribe] Gemini falhou') + return null + } + const data: any = await res.json() + const text: string | undefined = data?.candidates?.[0]?.content?.parts?.[0]?.text + return text?.trim() || null + } catch (err) { + logger.warn({ err, contentType }, '[media-transcribe] erro (não-fatal)') + return null + } +}