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)
|
type MessageType @default(TEXT)
|
||||||
body String?
|
body String?
|
||||||
caption String?
|
caption String?
|
||||||
|
transcription String? // transcrição (áudio) ou descrição (imagem) via IA
|
||||||
mediaUrl String?
|
mediaUrl String?
|
||||||
mediaPath String?
|
mediaPath String?
|
||||||
mimeType 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 type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
|
||||||
import { hookBus } from '../../../core/hook-bus'
|
import { hookBus } from '../../../core/hook-bus'
|
||||||
import { invalidateChatListCache } from "../../chats/chat.cache"
|
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) */
|
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||||
const MEDIA_DIR = path.resolve('./media')
|
const MEDIA_DIR = path.resolve('./media')
|
||||||
@@ -986,12 +987,28 @@ export class MessageHandler {
|
|||||||
this.persistSticker(msg, mediaUrl).catch(() => {})
|
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')
|
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
|
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> {
|
private async persistSticker(msg: WAMessage, wasabiPath: string): Promise<void> {
|
||||||
const stickerMsg = msg.message?.stickerMessage
|
const stickerMsg = msg.message?.stickerMessage
|
||||||
if (!stickerMsg) return
|
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