feat(media): transcrição de áudio + descrição de imagem no motor (via Gemini)

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 08:33:42 +02:00
parent 734b250238
commit 7893a05f63
3 changed files with 78 additions and 0 deletions
@@ -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?
@@ -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<void> {
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<void> {
const stickerMsg = msg.message?.stickerMessage
if (!stickerMsg) return
@@ -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<string | null> {
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 (12 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
}
}