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:
@@ -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?
|
||||
|
||||
+17
@@ -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 (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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user