8 Commits

Author SHA1 Message Date
VPS 4 Deploy Agent a5fffe25d2 fix(secretaria): SEC-06/07/14 — provider OpenAI, telemetria e digitando...
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
SEC-06: ChatbotService reescrito com abstração aiCall() que despacha para
  GEMINI ou OPENAI baseado em bot.credential.provider. Fallback automático:
  se o provider primário falha (quota, rate limit), tenta a próxima credencial
  da instância com provider diferente.

SEC-07: Divergência entre LLMProvider Prisma (GEMINI/OPENAI) e Secretária
  (gemini/openai/anthropic/ollama) documentada com comentário explícito no
  topo do service. Chatbot Rápido é intencionalmente mais simples.

SEC-13 (bônus): logger.info no ChatbotService agora inclui inputTokens e
  outputTokens por chamada para observabilidade de consumo.

SEC-14: sendPresenceUpdate('composing', jid) antes de chamar a IA e
  sendPresenceUpdate('paused', jid) após enviar a resposta — tanto no
  ChatbotService quanto no listener ext:message.new da Secretária.
  O usuário no WhatsApp vê "digitando..." enquanto a IA processa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 05:35:38 +02:00
VPS 4 Deploy Agent 3adbc0b59d fix(secretaria): corrige 7 inconsistências do issue #14
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
SEC-01+SEC-11: /secretaria/ask agora envia reply via WhatsApp (sock.sendMessage)
  e persiste a mensagem no banco Prisma após brain.chat(), com notify Socket.IO.

SEC-02+SEC-03: MessageHandler emite hookBus 'ext:message.new' para todo msg
  recebido. Listener no ext-api auto-dispara a Secretária apenas se NÃO
  houver AIBot ativo na instância (exclusão mútua — chatbot tem prioridade).

SEC-04: ChatListItem exibe ícone BotOff (âmbar) quando chat.bot_paused = true,
  com tooltip "Bot pausado — aguardando atendente".

SEC-05: chatToLegacy() mapeia c.botPaused → bot_paused na bridge legada.
  NewWhatsChat recebe campo bot_paused opcional.

SEC-08: POST /secretaria/ask valida que existe instância CONNECTED antes
  de criar conversa. Retorna 503 se nenhuma instância disponível.

SEC-10: POST /api/chatbot/bots/:instanceId valida que credentialId pertence
  ao mesmo tenantId antes de criar o bot. Retorna 403 se não pertencer.

SEC-12: chatBotState.resumeBot() limpa botSummary (= null) ao retomar,
  evitando contexto defasado após escalação humana.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 05:29:19 +02:00
VPS 4 Deploy Agent 2c1acd8ce8 feat(soc): add Docker container log collection to Promtail + app alert rules
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
- Mount rootless Docker socket and container log path into Promtail so it
  can discover and tail all application container logs via docker_sd_configs
- Add 3 application-level Grafana alert rules targeting newwhats-backend-prod:
  - BACKEND - Exceção Não Tratada (unhandledRejection, TypeError, etc.)
  - BACKEND - Colisão Prisma P2002 (unique constraint violations)
  - BACKEND - Erros HTTP 5xx (5xx response codes)
- Wire default notification policy to gitea-bridge (was routing to "empty")
- Full self-healing pipeline now covers: infra alerts + application runtime errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 05:01:41 +02:00
VPS 4 Deploy Agent db9e1c9db2 fix(history): salva mediaPayload em processHistoryBatch + recupera msgs antigas
processHistoryBatch não incluía mediaPayload no createMany, tornando
impossível o re-download de mídias recebidas via history sync (que é a
maioria das mensagens ao reconectar a sessão).

1. Inclui mediaPayload no objeto de cada row para mensagens de mídia.
2. Após o createMany, dispara updateMany para preencher mediaPayload em
   registros que já existiam no DB com o campo null (mensagens antigas
   recebidas antes desta correção), sem bloquear o event loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 03:58:10 +02:00
VPS 4 Deploy Agent a8d404deb5 fix(media): aplica estado 'não disponível' a imagem, vídeo, documento e sticker
Estende o tratamento de erro 400/404 do redownload para todos os tipos
de mídia. Quando mediaUnavailable=true (payload ausente no backend),
cada tipo exibe sua mensagem específica e desabilita o clique:
- Imagem: 'Imagem não disponível'
- Vídeo: 'Vídeo não disponível'
- Documento: 'Arquivo não disponível' + cursor-default
- Sticker: 'Figurinha não disponível'
DocumentBubble recebe nova prop mediaUnavailable para controle interno.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 03:31:59 +02:00
VPS 4 Deploy Agent 3cdac0b7ad fix(audio): trata erro 400/404 no redownload com estado 'Áudio não disponível'
Mensagens antigas sem mediaPayload retornam 400 do backend (sem como
re-solicitar ao WhatsApp). O erro agora é capturado em handleRedownload,
que seta mediaUnavailable=true — o botão fica desabilitado e exibe
'Áudio não disponível' em vez de explodir com Uncaught (in promise).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 03:26:26 +02:00
VPS 4 Deploy Agent bbde5ceacb fix(audio): corrige redownload de áudio + move botão de velocidade antes do waveform
1. MessageArea: handleRedownloadMedia usava selectedChat?.instanceId (undefined
   no formato legado) — substituído por instance_name ?? instanceId. O clique
   em 'Toque para baixar áudio' agora chama o backend corretamente.

2. MessageBubble/AudioBubble: botão de velocidade (1×/1.5×/2×) movido para
   entre o botão play e as barras do waveform, conforme layout do WhatsApp.
   A linha inferior agora exibe apenas o tempo decorrido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 03:19:45 +02:00
VPS 4 Deploy Agent 1fc139043f fix(whatsapp): mensagens do celular perdidas para contatos @lid + crash P2002
Três correções independentes para mensagens enviadas/recebidas via celular:

1. WhatsAppConnectionManager: o listener CB:message agora captura também
   recipient_pn (InfiniteAPI v7+) além de peer_recipient_pn (Baileys oficial),
   garantindo que o PN correto do destinatário seja gravado no peerRecipientHints
   antes do messages.upsert ser emitido.

2. MessageHandler.processMessage: adicionado fast path fromMe=false via
   remoteJidAlt para contatos @lid (InfiniteAPI já resolve o PN do remetente).
   Para fromMe=true onde InfiniteAPI resolveu @lid→nosso próprio JID incorretamente,
   recupera o JID correto via peerRecipientHints antes de descartar como self-chat.

3. MessageHandler.processHistoryBatch: corrigido guard senderPn que não testava
   !msg.key.fromMe; para mensagens enviadas, senderPn é o nosso próprio JID,
   não o destinatário — o que criava self-chats corrompidos no DB.

4. whatsapp.send.routes: adicionado .catch P2002 no upsert da rota POST /send;
   race condition com eco Baileys causava unhandled rejection → exit(1) no Node 20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 03:10:50 +02:00
14 changed files with 409 additions and 133 deletions
@@ -108,7 +108,7 @@ export const chatBotState = {
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false, botSummary: null } }),
updateSummary: (chatId: string, botSummary: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
@@ -118,6 +118,12 @@ export function buildChatbotRoutes(): Router {
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
return
}
// SEC-10: garante que a credencial pertence a este tenant
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
if (!cred) {
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
return
}
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(bot)
} catch (err: any) {
@@ -1,19 +1,28 @@
/**
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
* chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido).
*
* Padrão Cérebro (instrucoes.md §5):
* - Não envia histórico completo para economizar tokens
* - Mantém resumo de 2 frases por chat (botSummary no banco)
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
*
* Providers suportados (SEC-06/SEC-07):
* - GEMINI → Google Generative AI (via SDK)
* - OPENAI → OpenAI Chat Completions (via fetch)
* O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois.
* A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama
* via configuração de plugin — divergência intencional, pois os dois sistemas
* têm arquiteturas independentes.
*/
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai'
import { GoogleGenerativeAI } from '@google/generative-ai'
import type { WASocket } from '../whatsapp/engine'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import { botRepo, chatBotState } from './chatbot.repository'
import type { Server as SocketIOServer } from 'socket.io'
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
// ─── Cache de clientes Gemini ─────────────────────────────────────────────────
const geminiClients = new Map<string, GoogleGenerativeAI>()
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
@@ -23,34 +32,82 @@ function getGeminiClient(apiKey: string): GoogleGenerativeAI {
return geminiClients.get(apiKey)!
}
function getModel(apiKey: string, modelName: string): GenerativeModel {
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName })
// ─── Abstração de provider (SEC-06) ──────────────────────────────────────────
type Provider = 'GEMINI' | 'OPENAI'
interface AiResult {
text: string
inputTokens: number
outputTokens: number
}
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
async function aiCall(opts: {
provider: Provider
apiKey: string
model: string
systemPrompt: string
userPrompt: string
maxTokens?: number
temperature?: number
}): Promise<AiResult> {
const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts
async function classifyIntent(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<'resolve' | 'escalate'> {
const prompt = [
if (provider === 'OPENAI') {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: maxTokens,
temperature,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
return {
text: (data.choices[0].message.content as string).trim(),
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
}
}
// Default: GEMINI
const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' })
const result = await geminiModel.generateContent({
contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }],
generationConfig: { maxOutputTokens: maxTokens, temperature },
})
const usage = result.response.usageMetadata
return {
text: result.response.text().trim(),
inputTokens: usage?.promptTokenCount ?? 0,
outputTokens: usage?.candidatesTokenCount ?? 0,
}
}
// ─── Classificação de intenção ────────────────────────────────────────────────
async function classifyIntent(opts: {
provider: Provider; apiKey: string; model: string
userMsg: string; summary: string | null; systemPrompt: string
}): Promise<'resolve' | 'escalate'> {
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
const userPrompt = [
`Você é um classificador de intenção para um chatbot de atendimento.`,
`Prompt do atendente: ${systemPrompt}`,
summary ? `Contexto da conversa até agora: ${summary}` : '',
summary ? `Contexto da conversa: ${summary}` : '',
`Nova mensagem do cliente: "${userMsg}"`,
`Responda APENAS com o número:`,
`1 = Consigo responder essa pergunta dentro do meu papel`,
`2 = Precisa de um agente humano`,
`Responda APENAS com o número: 1 = resolver, 2 = escalar humano`,
].filter(Boolean).join('\n')
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 2, temperature: 0 },
})
const text = result.response.text().trim()
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 })
return text.startsWith('2') ? 'escalate' : 'resolve'
} catch (err) {
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
@@ -60,53 +117,37 @@ async function classifyIntent(
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<string> {
const prompt = [
systemPrompt,
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
`\nMensagem do cliente: "${userMsg}"`,
`\nResponda de forma natural, breve e direta. Não use markdown.`,
async function generateResponse(opts: {
provider: Provider; apiKey: string; model: string
userMsg: string; summary: string | null; systemPrompt: string
}): Promise<AiResult> {
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
const userPrompt = [
summary ? `Contexto da conversa até agora:\n${summary}` : '',
`Mensagem do cliente: "${userMsg}"`,
`Responda de forma natural, breve e direta. Não use markdown.`,
].filter(Boolean).join('\n')
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
})
return result.response.text().trim()
return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 })
}
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
async function updateSummary(
currentSummary: string | null,
userMsg: string,
botMsg: string,
model: GenerativeModel
): Promise<string> {
const prompt = [
currentSummary
? `Resumo atual da conversa: ${currentSummary}`
: 'Esta é a primeira troca da conversa.',
`Nova troca:`,
`Cliente: "${userMsg}"`,
`Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
`Responda apenas com o resumo, sem introdução.`,
async function updateSummary(opts: {
provider: Provider; apiKey: string; model: string
currentSummary: string | null; userMsg: string; botMsg: string
}): Promise<string> {
const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts
const userPrompt = [
currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.',
`Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases. Responda só com o resumo, sem introdução.`,
].join('\n')
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
})
return result.response.text().trim()
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 })
return text
} catch {
// Em caso de erro, mantém o resumo anterior
return currentSummary ?? ''
}
}
@@ -145,15 +186,16 @@ export class ChatbotService {
if (!hasKeyword) return
}
try {
const model = getModel(bot.credential.apiKey, bot.model)
const summary = chatState.botSummary ?? null
const provider = (bot.credential.provider ?? 'GEMINI') as Provider
const { apiKey } = bot.credential
const { model } = bot
const summary = chatState.botSummary ?? null
try {
// 4. Micro-prompt: intenção
const intent = await classifyIntent(text, summary, bot.systemPrompt, model)
const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
if (intent === 'escalate') {
// Pausa o bot e notifica via Socket.IO
await chatBotState.pauseBot(chatId)
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
chatId,
@@ -163,13 +205,32 @@ export class ChatbotService {
return
}
// 5. Gera resposta
const responseText = await generateResponse(text, summary, bot.systemPrompt, model)
// 5. Indicador de digitação (SEC-14)
await sock.sendPresenceUpdate('composing', jid).catch(() => {})
// 6. Envia via Baileys
const sent = await sock.sendMessage(jid, { text: responseText })
// 6. Gera resposta (com fallback para outro provider se falhar)
let result: AiResult
try {
result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
} catch (primaryErr) {
// Fallback: tenta outra credencial da mesma instância com provider diferente
const fallbackCred = await prisma.aICredential.findFirst({
where: { tenantId, instanceId, NOT: { id: bot.credentialId } },
})
if (!fallbackCred) throw primaryErr
logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback')
const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider
result = await generateResponse({
provider: fbProvider, apiKey: fallbackCred.apiKey, model,
userMsg: text, summary, systemPrompt: bot.systemPrompt,
})
}
// 7. Persiste mensagem do bot no banco
// 7. Para indicador de digitação + envia
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
const sent = await sock.sendMessage(jid, { text: result.text })
// 8. Persiste mensagem do bot no banco
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
if (chat) {
const botMsg = await prisma.message.create({
@@ -181,22 +242,21 @@ export class ChatbotService {
messageId: sent?.key.id ?? `bot-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: responseText,
body: result.text,
status: 'SENT',
timestamp: new Date(),
},
})
// Notifica frontend
this.io.to(`chat:${chatId}`).emit('message:new', botMsg)
}
// 8. Atualiza Cérebro
const newSummary = await updateSummary(summary, text, responseText, model)
// 9. Atualiza Cérebro + telemetria (SEC-13)
const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text })
await chatBotState.updateSummary(chatId, newSummary)
logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada')
logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada')
} catch (err) {
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
}
}
@@ -285,24 +285,29 @@ export class WhatsAppConnectionManager {
const msgHandler = new MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService)
this.msgHandlers.set(instanceId, msgHandler)
// ─── Intercepta stanzas brutas pra extrair peer_recipient_pn ─────────
// O Baileys (decode-wa-message.js) expõe só sender_pn/participant_pn no
// key — mas NÃO expõe peer_recipient_pn, que é a única fonte de verdade
// pro telefone real do DESTINATÁRIO em mensagens fromMe=true enviadas
// pelo celular pra contatos @lid novos (sem mapeamento salvo).
// ─── Intercepta stanzas brutas pra extrair PN real do destinatário ──
// Dois cenários distintos de mensagens do celular que chegam sem PN:
//
// Sem essa dica, `resolveLidToPhoneJid` retornaria null e a mensagem
// seria descartada — o usuário relatou exatamente esse sintoma ("envio
// pelo celular da sessão pro número X e a msg não aparece na inbox").
// Cenário A (Baileys oficial): mensagem fromMe=true com remoteJid=@lid
// O campo peer_recipient_pn na stanza bruta contém o PN do destinatário.
// Baileys não expõe esse campo em key, por isso lemos aqui.
//
// O listener roda em paralelo com o handler interno do Baileys: ambos
// são callbacks síncronos do EventEmitter de ws, então o mapa é
// Cenário B (InfiniteAPI v7+): mensagem fromMe=true onde o InfiniteAPI
// resolve automaticamente @lid → nosso próprio JID (usando sender_pn
// da stanza, que para fromMe=true é NOSSO PN, não o do destinatário).
// O PN correto do destinatário está em recipient_pn na stanza.
// Sem essa dica, processMessage descarta a mensagem como self-chat.
//
// O listener é síncrono (mesmo EventEmitter do ws), então o mapa é
// populado antes do `messages.upsert` ser emitido de forma assíncrona.
;(sock.ws as any).on('CB:message', (node: any) => {
const msgId = node?.attrs?.id
const peerRecipientPn = node?.attrs?.peer_recipient_pn
if (msgId && peerRecipientPn) {
msgHandler.setPeerRecipientHint(msgId, peerRecipientPn)
if (!msgId) return
// peer_recipient_pn: Cenário A (@lid não resolvido, Baileys oficial)
// recipient_pn: Cenário B (InfiniteAPI resolveu @lid para nosso PN)
const hint = node?.attrs?.peer_recipient_pn ?? node?.attrs?.recipient_pn
if (hint) {
msgHandler.setPeerRecipientHint(msgId, hint)
}
})
@@ -279,8 +279,12 @@ export class MessageHandler {
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = normalizeJid(remoteJidAlt)
} else {
// 2. senderPn / participantPn
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
// 2. senderPn / participantPn — SOMENTE para mensagens recebidas (fromMe=false).
// Para fromMe=true, senderPn é o NOSSO próprio JID (somos o remetente),
// não o JID do destinatário — usar aqui criaria um self-chat corrompido no DB.
const senderPn = !msg.key.fromMe
? ((msg.key as any).senderPn || (msg.key as any).participantPn)
: null
if (senderPn) {
jidRaw = normalizeJid(senderPn)
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
@@ -368,6 +372,7 @@ export class MessageHandler {
null
const isGroup = jid.endsWith('@g.us')
const isMed = this.isMediaMessage(contentType)
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
@@ -383,12 +388,33 @@ export class MessageHandler {
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ' as const, // históricas são consideradas já lidas
timestamp: ts,
// Salva payload para permitir re-download posterior via /redownload-media
...(isMed ? { mediaPayload: this.buildMediaPayload(key, message) as any } : {}),
}]
})
if (rows.length > 0) {
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
totalSaved += count
// Recupera mediaPayload para mensagens de mídia que já existiam no DB
// sem esse campo (criadas antes desta correção ou via path antigo).
// Roda em paralelo sem bloquear o loop principal.
const mediaRows = rows.filter(r => r.mediaPayload != null)
if (mediaRows.length > 0) {
Promise.all(
mediaRows.map(r =>
prisma.message.updateMany({
where: {
instanceId: this.instanceId,
messageId: r.messageId,
mediaPayload: null,
},
data: { mediaPayload: r.mediaPayload },
}).catch(() => {})
)
).catch(() => {})
}
}
} catch (err) {
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
@@ -452,7 +478,17 @@ export class MessageHandler {
*/
let originalLidJid: string | null = null
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
// Fast path para fromMe=false: InfiniteAPI preenche remoteJidAlt com o PN
// do remetente (que é o contato) quando addressing_mode=pn. Para fromMe=true,
// remoteJidAlt contem o NOSSO próprio PN (senderAlt = sender_pn = nosso número)
// — por isso o fast path só é seguro para mensagens recebidas.
const remoteJidAlt = (key as any).remoteJidAlt as string | undefined
let phoneJid: string | null = null
if (!key.fromMe && remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
phoneJid = normalizeJid(remoteJidAlt)
} else {
phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
}
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
@@ -480,12 +516,36 @@ export class MessageHandler {
const fromMe = key.fromMe ?? false
const remoteJidRaw = key.remoteJid
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = normalizeJid(remoteJidRaw)
let remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais).
//
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
// a um contato LID, o InfiniteAPI pode ter resolvido remoteJid para o nosso próprio
// JID (usando sender_pn = nosso PN em vez de recipient_pn = PN do destinatário).
// Nesse caso, o CB:message listener armazenou recipient_pn em peerRecipientHints.
// Tentamos recuperar o JID correto antes de descartar como self-chat.
const ownJid = normalizeJid(this.sock.user?.id ?? '')
if (ownJid && remoteJid === ownJid) return
if (ownJid && remoteJid === ownJid) {
if (fromMe) {
const hint = this.peerRecipientHints.get(messageId)
if (hint) {
this.peerRecipientHints.delete(messageId)
const correctJid = normalizeJid(hint)
if (correctJid && correctJid.endsWith('@s.whatsapp.net') && correctJid !== ownJid) {
remoteJid = correctJid
;(key as any).remoteJid = correctJid
} else {
return
}
} else {
return
}
} else {
return
}
}
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
@@ -652,6 +712,19 @@ export class MessageHandler {
}).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
)
}
// ── 9. Emite para plugins externos (ex: Secretária IA via ext:message.new) ─
if (!fromMe && body) {
setImmediate(() =>
hookBus.emit('ext:message.new', {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
}).catch((err: unknown) => logger.error({ err }, '[HookBus] Erro em ext:message.new'))
)
}
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -223,7 +223,10 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
richPayloadData = { text, footer, nativeCarousel }
}
const msg = await prisma.message.upsert({
// Race condition: o eco do Baileys pode chegar ANTES desta linha e criar o
// registro primeiro, fazendo o upsert falhar com P2002 (unique constraint).
// Nesses casos, buscamos a mensagem que já foi criada pelo eco.
let msg = await prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
@@ -239,6 +242,15 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
timestamp: new Date(),
},
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
}).catch(async (err: any) => {
if (err?.code === 'P2002') {
// Eco do Baileys criou o registro antes de nós — busca e retorna ele
const existing = await prisma.message.findUnique({
where: { instanceId_messageId: { instanceId, messageId } },
})
if (existing) return existing
}
throw err
})
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
@@ -7,7 +7,7 @@ import {
VolumeX, Star, StarOff, Archive, ArchiveRestore,
Trash2, MailOpen, Volume2, PinOff,
Mic, FileText, Video, Image as ImageIcon,
AlertCircle,
AlertCircle, BotOff,
} from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import {
@@ -225,6 +225,11 @@ export default function ChatListItem({
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
{chat.bot_paused && (
<span title="Bot pausado — aguardando atendente">
<BotOff className="w-4 h-4 text-amber-500" />
</span>
)}
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
{chat.unread_count > 0 && (
@@ -61,10 +61,11 @@ const MessageArea: React.FC<MessageAreaProps> = ({
// ── Re-download de mídia não baixada ─────────────────────────────────────
const handleRedownloadMedia = useCallback(async (msg: Message) => {
const instanceId = selectedChat?.instanceId
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
await mediaApi.redownload(instanceId, String(msg.id))
}, [selectedChat?.instanceId])
}, [selectedChat?.instance_name, selectedChat?.instanceId])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
@@ -123,7 +123,15 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
)}
</button>
{/* Waveform + meta */}
{/* Speed — antes do waveform, alinhado ao centro vertical */}
<button
onClick={cycleSpeed}
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none shrink-0 w-7 text-center"
>
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
</button>
{/* Waveform + tempo */}
<div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-end gap-[2px] h-7">
{waveHeights.map((h, i) => (
@@ -137,17 +145,9 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
/>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
</span>
<button
onClick={cycleSpeed}
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none"
>
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
</button>
</div>
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
</span>
</div>
</div>
{TimestampRow}
@@ -163,6 +163,7 @@ interface DocumentBubbleProps {
TimestampRow: React.ReactNode
onRedownload?: () => void
isRedownloading?: boolean
mediaUnavailable?: boolean
}
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
@@ -177,7 +178,7 @@ const DOC_TYPES: Record<string, { bg: string; label: string }> = {
csv: { bg: '#276749', label: 'CSV' },
}
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading }) => {
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading, mediaUnavailable }) => {
const [previewOpen, setPreviewOpen] = useState(false)
// Derive extension from media_type or media_url
@@ -203,6 +204,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
const isPdf = ext === 'pdf'
const handleClick = () => {
if (mediaUnavailable) return
if (!fullMediaUrl) {
onRedownload?.()
return
@@ -219,7 +221,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
<div>
<div
onClick={handleClick}
className="flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 cursor-pointer hover:bg-[#e8eaed] transition-all active:scale-[0.98] min-w-[220px] max-w-[280px]"
className={`flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 transition-all min-w-[220px] max-w-[280px] ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed] active:scale-[0.98]'}`}
>
{/* Type icon */}
<div
@@ -237,7 +239,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
{filename}
</span>
<span className="text-[11px] text-[#667781] mt-[2px]">
{isRedownloading ? 'Baixando...' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Arquivo não disponível' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
</span>
</div>
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
@@ -328,16 +330,20 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [listOpen, setListOpen] = useState(false);
const [isRedownloading, setIsRedownloading] = useState(false);
const [mediaUnavailable, setMediaUnavailable] = useState(false);
const handleRedownload = useCallback(async () => {
if (!onRedownloadMedia || isRedownloading) return
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
setIsRedownloading(true)
try {
await onRedownloadMedia(msg)
} catch (err: any) {
const status = err?.response?.status ?? err?.status
if (status === 400 || status === 404) setMediaUnavailable(true)
} finally {
setIsRedownloading(false)
}
}, [onRedownloadMedia, msg, isRedownloading])
}, [onRedownloadMedia, msg, isRedownloading, mediaUnavailable])
const isOut = msg.direction === 'out';
const showActionsLeft = isOut;
const showActionsRight = !isOut;
@@ -614,8 +620,8 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
className={`relative group/media w-[268px] h-[200px] bg-black/5 overflow-hidden ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
>
{fullMediaUrl ? (
<img
@@ -624,10 +630,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className={`flex flex-col items-center justify-center w-full h-full gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">Toque para baixar</span></>
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">{mediaUnavailable ? 'Imagem não disponível' : 'Toque para baixar'}</span></>
}
</div>
)}
@@ -654,8 +660,8 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
className={`relative w-[268px] h-[200px] bg-black overflow-hidden group/vid ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
>
{fullMediaUrl ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors">
@@ -664,10 +670,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2">
<div className={`absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
{isRedownloading
? <div className="w-8 h-8 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">Toque para baixar</span></>
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">{mediaUnavailable ? 'Vídeo não disponível' : 'Toque para baixar'}</span></>
}
</div>
)}
@@ -696,14 +702,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
return (
<div>
<div
onClick={handleRedownload}
className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] cursor-pointer hover:bg-[#e8eaed] transition-colors"
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
}
<span className="text-[12px] text-[#667781]">{isRedownloading ? 'Baixando...' : 'Toque para baixar áudio'}</span>
<span className="text-[12px] text-[#667781]">
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
</span>
</div>
{TimestampRow}
</div>
@@ -711,7 +719,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
}
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
case 'document':
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} />;
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} mediaUnavailable={mediaUnavailable} />;
case 'sticker':
return (
<div>
@@ -719,14 +727,14 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
: (
<div
onClick={handleRedownload}
className="flex items-center gap-2 text-[13px] text-[#667781] italic cursor-pointer hover:text-[#111b21] transition-colors"
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2 text-[13px] text-[#667781] italic transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:text-[#111b21]'}`}
>
{isRedownloading
? <div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <Star className="w-4 h-4" />
}
{isRedownloading ? 'Baixando...' : 'Toque para baixar figurinha'}
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Figurinha não disponível' : 'Toque para baixar figurinha'}
</div>
)
}
@@ -64,6 +64,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
unread_count: c.unreadCount,
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
}
}
@@ -93,6 +93,7 @@ export interface NewWhatsChat {
is_pinned: boolean | null
is_archived: boolean | null
muted_until?: string | null
bot_paused?: boolean | null
labels?: Array<{ label_id: string; name?: string }>
}
@@ -22,6 +22,7 @@ import type { Knex } from 'knex'
import type { WhatsAppConnectionManager } from '../../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager'
import type { PluginConfigStore } from '../../../backend/src/core/plugin-config'
import type { HookBus } from '../../../backend/src/core/hook-bus'
import type { Server as SocketServer } from 'socket.io'
let dragonfly: any;
if (process.env.NODE_ENV === 'production') {
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
@@ -76,9 +77,84 @@ export function buildExtRoutes(
db: Knex,
config: PluginConfigStore,
hooks: HookBus,
io?: SocketServer,
): Router {
const router = Router()
// ── Helper: envia reply da IA via WA e persiste no banco principal ─────────
async function sendSecretariaReply(opts: {
instanceId: string
tenantId: string
chatId: string
jid: string
reply: string
}): Promise<void> {
const { instanceId, tenantId, chatId, jid, reply } = opts
const sock = manager?.getSocket(instanceId)
if (!sock) return
const sent = await sock.sendMessage(jid, { text: reply }).catch(() => null)
const persisted = await prisma.message.create({
data: {
tenantId,
instanceId,
chatId,
remoteJid: jid,
messageId: sent?.key.id ?? `sec-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: reply,
status: 'SENT',
timestamp: new Date(),
},
}).catch(() => null)
if (persisted && io) {
io.to(`chat:${chatId}`).emit('message:new', persisted)
}
}
// ── SEC-02+SEC-03: auto-trigger da Secretária para mensagens recebidas ────
// Só processa se NÃO houver AIBot ativo (exclusão mútua com Chatbot Rápido).
hooks.register('ext:message.new', async (data: any) => {
const { tenantId, instanceId, chatId, jid, text } = data as {
tenantId: string; instanceId: string; chatId: string; jid: string; text: string
}
if (!text?.trim()) return
// Exclusão mútua: chatbot rápido tem prioridade se estiver ativo
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
if (activeBot) return
const extKey = `${tenantId}:${jid}`
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first()
if (!conv) {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
if (!agent) return
const [newConv] = await db('sec_conversations').insert({
id: uuid(),
agent_id: agent.id,
contact_name: jid,
protocol_number: ProtocolEngine.generateProtocolNumber(),
status: 'active',
ext_chat_id: extKey,
handoff_mode: 'ia',
}).returning('*')
conv = newConv
}
if (conv.handoff_mode === 'humano') return
// SEC-14: indicador de digitação antes de chamar a IA
const sock = manager?.getSocket(instanceId)
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks })
await sock?.sendPresenceUpdate('paused', jid).catch(() => {})
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply })
})
// ── Escalation notification listener ──────────────────────────────────────
// When escalar_humano tool fires, send a WA message to the configured admin phone.
hooks.register('ext:escalated', async (data: any) => {
@@ -696,6 +772,16 @@ export function buildExtRoutes(
}
try {
// SEC-08: valida que existe instância conectada para este tenant
const connectedInstance = await prisma.instance.findFirst({
where: { tenantId, status: 'CONNECTED' },
select: { id: true },
})
if (!connectedInstance) {
res.status(503).json({ error: 'Nenhuma instância WhatsApp conectada para este tenant.' })
return
}
const extKey = `${tenantId}:${chatId}`
let conv = await db('sec_conversations')
@@ -743,6 +829,22 @@ export function buildExtRoutes(
tenantId,
})
// SEC-01+SEC-11: envia reply via WA e persiste no banco principal
const chat = await prisma.chat.findFirst({
where: { tenantId, remoteJid: chatId },
orderBy: { updatedAt: 'desc' },
select: { id: true, instanceId: true },
})
if (chat) {
await sendSecretariaReply({
instanceId: chat.instanceId,
tenantId,
chatId: chat.id,
jid: chatId,
reply,
})
}
res.json({
reply,
conversationId: conv.id,
@@ -23,7 +23,7 @@ const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
const { app, prisma, db, config, hooks, logger, httpServer } = ctx
const { app, prisma, db, config, hooks, logger, httpServer, io } = ctx
// ── REST ────────────────────────────────────────────────────────────────
const manager = globalThis.__whatsAppManager
@@ -32,7 +32,7 @@ const plugin: PluginInstance = {
}
const authMiddleware = buildApiKeyAuth(prisma)
const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks)
const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks, io)
app.use('/api/ext/v1', authMiddleware, extRouter)
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')
+2
View File
@@ -25,6 +25,8 @@ services:
volumes:
- /var/log:/var/log:ro
- /opt/soc/promtail:/etc/promtail:ro
- /run/user/1000/docker.sock:/var/run/docker.sock:ro
- /home/deploy/.local/share/docker/containers:/home/deploy/.local/share/docker/containers:ro
command: -config.file=/etc/promtail/config.yml
networks:
- soc