Arquitetura do Canal WhatsApp
Esta página cobre o design interno da integração WhatsApp para contribuidores.
Fluxo de mensagens
Seção intitulada “Fluxo de mensagens”Contato existente — mensagem de texto
Seção intitulada “Contato existente — mensagem de texto”Usuário envia mensagem no WhatsApp → whatsapp-api armazena no Postgres, dispara POST /channels/whatsapp/webhook → whatsappWebhookHandler (Express) responde 200 imediatamente → WhatsAppPlugin.handleInboundWebhook() → WhatsAppAdapter.toUnifiedIncomingMessage() → IUnifiedIncomingMessage → (download de mídia + normalização — veja abaixo) → messageHandler() (definido pelo ChannelManager) → ActionExecutor.handleIncomingMessage() → verificação de pareamento (PairingService) → ActionExecutor.handleChatMessage() → buildBusinessContextForMessage() # lê config do Modo Profissional → sendMessage("⏳ Thinking...") # placeholder enviado ao WhatsApp → ChannelMessageService.sendMessage() → injeção de contexto + histórico se tarefa for nova → workerTaskManager.getOrBuildTask() # inicia Claude Code (ACP) → callback de stream ACP por chunk → editMessage() → WhatsApp descarta chunks intermediários → editMessage final ({ replyMarkup: __aionuiFinal }) → WhatsAppPlugin.editMessage() # replyMarkup verdadeiro → envia → WhatsAppPlugin.sendMessage() # HTTP POST para o whatsapp-api → whatsapp-api entrega a resposta ao usuárioContato existente — mensagem de imagem
Seção intitulada “Contato existente — mensagem de imagem”Usuário envia imagem (com legenda opcional) → WhatsAppAdapter.toUnifiedIncomingMessage() → contentType = 'photo' → attachments = [{ type: 'photo', fileId: media.path }] → WhatsAppPlugin.handleInboundWebhook() → downloadMediaToTemp('/chat/public/image/download?q=...') → GET serverUrl+path com auth JWT → salva em $TEMP/wa-media-{timestamp}.jpg → attachments[0].fileName = localPath → normalizeMediaContent() → { type: 'text', text: '[Image: /tmp/wa-media-...jpg]\nCaption: ...' } → ActionExecutor → handleChatMessage() com texto normalizado → IA recebe caminho da imagem + legenda como mensagem de textoContato existente — mensagem de voz
Seção intitulada “Contato existente — mensagem de voz”Usuário envia mensagem de voz (OGG/Opus) → WhatsAppAdapter.toUnifiedIncomingMessage() → contentType = 'voice' → attachments = [{ type: 'voice', fileId: media.path }] → WhatsAppPlugin.handleInboundWebhook() → downloadMediaToTemp('/chat/public/audio/download?q=...') → salva em $TEMP/wa-media-{timestamp}.ogg → transcribeAudio(localPath) → convertToWav() # ffmpeg: OGG/Opus → WAV 16 kHz mono → SpeechToTextService.transcribe() # whisper local ou provedor em nuvem → deleta o arquivo .wav temporário → retorna texto transcrito → se transcrito: unified.content = { type: 'text', text: transcricao } → se falhar: sendMessage(sttErrorNotice(err)) → return → ActionExecutor → handleChatMessage() com texto transcrito → IA recebe as palavras faladas como mensagem de textoNovo contato — Modo Profissional ativo
Seção intitulada “Novo contato — Modo Profissional ativo”Cliente desconhecido envia mensagem → ActionExecutor.handleIncomingMessage() → PairingService.autoApproveUser() # cria registro em assistant_users sem código → WhatsAppPlugin.sendMessage(welcome) # mensagem de boas-vindas configurada → [cai no fluxo normal de chat acima]Novo contato — Modo Profissional inativo
Seção intitulada “Novo contato — Modo Profissional inativo”Usuário desconhecido envia mensagem → ActionExecutor.handleIncomingMessage() → handlePairingShow() # exibe código de 6 dígitos no WhatsApp → aguarda aprovação manual no painelContato autorizado — chat pausado pelo profissional
Seção intitulada “Contato autorizado — chat pausado pelo profissional”Cliente autorizado envia mensagem enquanto o chat está pausado → ActionExecutor.handleIncomingMessage() → PairingService.isUserAuthorized() → true → ProcessConfig.get('assistant.whatsapp.businessMode') → bm.excludedChats.includes(chatId) → true → return # descartado silenciosamente — profissional responde manualmenteArquivos fonte
Seção intitulada “Arquivos fonte”Novos arquivos:
| Arquivo | Papel |
|---|---|
src/process/channels/plugins/whatsapp/WhatsAppAdapter.ts | Conversão pura: mapeia uma linha Message do whatsapp-api para IUnifiedIncomingMessage. Filtra mensagens de eco (fromMe) e eventos de sincronização histórica (info.type !== 'notify'). Popula attachments para mensagens de imagem/voz/áudio. |
src/process/channels/plugins/whatsapp/WhatsAppPlugin.ts | Plugin principal. Estende BasePlugin. Gerencia JWT, registro de webhook, envio/recebimento, download de mídia, conversão OGG→WAV, transcrição de voz, rastreamento de usuários ativos e estado de “pensando”, getOwnerPhone() para notificações do Modo Profissional. |
src/process/channels/plugins/whatsapp/index.ts | Re-exporta plugin + acessores singleton (getActiveWhatsAppPlugin). |
src/process/webserver/routes/whatsappChannelRoutes.ts | Rota Express para POST /channels/whatsapp/webhook. Responde 200 imediatamente (o whatsapp-api não tenta novamente), depois despacha para o singleton do plugin ativo. |
src/process/resources/assistant/therapy-receptionist/therapy-receptionist.md | Preset de assistente recepcionista profissional com agendamento via Google Calendar. |
src/process/resources/skills/whatsapp/SKILL.md | Skill carregada pelo agente desktop. Ensina a IA a emitir blocos [WA_SEND] e [WA_FETCH] quando o usuário pede para enviar ou ler mensagens do WhatsApp. |
src/process/task/WhatsAppCommandDetector.ts | Interpreta blocos [WA_SEND] e [WA_FETCH] no output da IA. Exporta detectWaCommands, hasWaCommands, stripWaCommands. |
src/process/task/WhatsAppMiddleware.ts | Executa os comandos WhatsApp: chama sendMessage ou fetchMessages no plugin ativo e emite uma resposta de sistema de volta à IA. |
src/process/channels/actions/WhatsAppPlatformActions.ts | Handlers dos comandos /pause e /resume no chat. Adiciona/remove o chatId de excludedChats na config. |
src/renderer/pages/conversation/components/WhatsAppAgentToggle.tsx | Switch por chat renderizado no cabeçalho da conversa. Lê e grava excludedChats via ConfigStorage direto do renderer. |
src/renderer/pages/conversation/hooks/WhatsAppWebContext.tsx | Contexto React global + provider (WhatsAppWebProvider) que possui o único WebviewHost persistente do WhatsApp Web. Expõe registerSlot e setWebVisible para painéis de conversa. Rastreia o bounding rect do elemento slot via ResizeObserver e posiciona o webview fixo sobre ele. |
whisper.cpp/ | Build local do whisper.cpp. Binário em whisper.cpp/build/bin/whisper-server, com symlink em /Volumes/server-ssd/bin/whisper-server. Modelo em whisper.cpp/models/ggml-base.bin. |
Arquivos modificados:
| Arquivo | Mudança |
|---|---|
src/process/channels/plugins/whatsapp/WhatsAppAdapter.ts | Adicionado preenchimento de attachments para tipos de mensagem photo, voice, audio, document. |
src/process/channels/plugins/whatsapp/WhatsAppPlugin.ts | Adicionado tipo WaFetchedMessage e método fetchMessages(remoteJid, limit) — chama GET /chat/findMessages/{instanceName} na API CodeChat. |
src/common/types/speech.ts | Adicionado 'local' a SpeechToTextProvider. Novo LocalSpeechToTextConfig { url?, language? }. Campo local? adicionado a SpeechToTextConfig. |
src/process/bridge/services/SpeechToTextService.ts | Adicionado transcribeWithLocal() — envia áudio multipart para um servidor compatível com whisper.cpp via HTTP. |
src/renderer/components/settings/SettingsModal/contents/ToolsModalContent.tsx | Opção “Whisper Local” no select de provedor; renderiza campos de URL e idioma para o provedor local. |
src/renderer/services/i18n/locales/en-US/settings.json | Adicionados speechToTextProviderLocal, speechToTextLocalUrl e labels do toggle por usuário no Modo Profissional. |
src/renderer/services/i18n/locales/pt-BR/settings.json | Mesmas adições em português. |
src/process/channels/types.ts | Adicionado 'whatsapp' a BuiltinPluginType, isBuiltinChannelPlatform, hasPluginCredentials e getChannelConversationName. Campos do Modo Profissional em IPluginConfigOptions. |
src/process/channels/core/ChannelManager.ts | Plugin registrado; adicionado a builtinStartableTypes; extração de credenciais; resolução de nome/ID. |
src/process/webserver/routes/apiRoutes.ts | Chama registerWhatsAppChannelRoutes(app). |
src/process/webserver/setup.ts | Adicionado /channels/whatsapp/webhook à lista de exclusão CSRF. |
src/process/channels/gateway/ActionExecutor.ts | Correções em finalReplyMarkup e lastMessageContent. Auto-aprovação e injeção de contexto profissional. Roteamento de /pause//resume. Verificação de exclusão por chat para usuários autorizados. |
src/process/channels/agent/ChannelMessageService.ts | Injeção de histórico + contexto profissional + correção de yoloMode. |
src/process/channels/pairing/PairingService.ts | Novo método autoApproveUser() para aprovação sem código. |
src/common/config/storage.ts | assistant.whatsapp.businessMode atualizado com campo excludedChats?: string[]. |
src/process/bridge/channelBridge.ts | Handler IPC channel.notify-business-mode. |
src/common/adapter/ipcBridge.ts | Método channel.notifyBusinessMode exposto ao renderer. |
src/renderer/components/settings/SettingsModal/contents/channels/WhatsAppConfigForm.tsx | Seção “Modo Profissional” com toggle, campos de configuração e Switch de exclusão por usuário autorizado. |
src/renderer/components/media/WebviewHost.tsx | Adicionada prop userAgent?: string; conectada ao atributo useragent do <webview> do Electron quando fornecida. |
src/renderer/main.tsx | Adicionado WhatsAppWebProvider na cadeia global de AppProviders para montar o webview singleton uma única vez na raiz do app. |
src/renderer/pages/conversation/components/ChatConversation.tsx | Adicionado componente WhatsAppConversationPanel com barra de abas Chat/WhatsApp Web; retorno antecipado para conversas WhatsApp (name.startsWith('wa-acp-')). WhatsAppAgentToggle adicionado ao cabeçalho do painel. |
src/renderer/pages/guid/components/QuickActionButtons.tsx | Pill de toggle rápido do Modo Profissional na tela inicial. |
src/process/task/AcpAgentManager.ts | Após resposta da IA, detecta blocos WA e chama processWaInMessage. |
src/process/task/GeminiAgentManager.ts | Idem. |
src/process/task/AionrsManager.ts | Idem. |
package.json | Adicionada dependência de desenvolvimento concurrently. Novos scripts: start:wa, start:wa:whisper. |
Nota — agente Gemini removido. As listas de arquivos acima são o changelog original do modo-negócio do WhatsApp. O ThairaAI removeu desde então os agentes Gemini (
GeminiAgentManager,GeminiDirectAgent,src/process/agent/gemini/**,src/process/worker/gemini.ts). O recepcionista de cliente-empresa agora é oThairaGatewayAgent(src/process/task/directAgent/ThairaGatewayAgent.ts, tipo de conversathaira-gateway), o agente interativo do dono/desktop é oAionrsManager, e o conjunto de ferramentas de calendário passou parasrc/process/task/directAgent/calendarTools.ts. Onde as seções abaixo citam uma classe Gemini, leia como seu equivalente atual.
Decisões de design
Seção intitulada “Decisões de design”Webview singleton do WhatsApp Web
Seção intitulada “Webview singleton do WhatsApp Web”A aba WhatsApp Web incorpora web.whatsapp.com via <webview> do Electron. Montar um <webview> dentro do WhatsAppConversationPanel diretamente destruiria e recriaria o componente a cada troca de conversa — exigindo novo QR code.
Solução: WhatsAppWebProvider (montado uma vez no AppProviders) possui o único <webview> e o renderiza como overlay position: fixed. Os painéis de conversa expõem um <div> de “slot” via registerSlot(el). O provider rastreia o getBoundingClientRect() do slot com um ResizeObserver e reposiciona o overlay fixo para coincidir com ele.
Detalhes-chave da implementação:
visibility: hiddenem vez dedisplay: none— O div do slot deve sempre terposition: absolute; inset: 0comvisibility: hidden/visible. Usardisplay: nonecolapsa o elemento, fazendogetBoundingClientRect()retornar zeros e causando o desaparecimento do webview.- Sem
keynoWhatsAppConversationPanel— Sem a propkey, o React reutiliza a instância do painel ao trocar entre conversas WhatsApp. Isso mantém o slot montado e o webview visível entre os chats. partition='persist:whatsapp-web'— O storage de sessão do Electron para o webview é isolado nessa chave de partição e sobrevive a reinicializações do app. O usuário lê o QR code uma única vez.- User agent do Chrome — O WhatsApp Web detecta navegadores não-Chrome e se recusa a carregar. O webview recebe uma string de user agent do Chrome 120 via prop
userAgentnoWebviewHost. - Border radius —
getComputedStyle(slotEl).borderRadiusé lido no momento da medição e aplicado ao container fixo comoverflow: hidden, fazendo o webview recortar com as bordas arredondadas do painel.
Auto-inicialização do servidor HTTP
Seção intitulada “Auto-inicialização do servidor HTTP”O servidor Express do ThairaAI só inicia quando o usuário ativa a funcionalidade Desktop WebUI. Sem ela, os POSTs de webhook do whatsapp-api receberiam “connection refused”.
WhatsAppPlugin.onStart() chama ensureWebServerRunning() antes de registrar o webhook. Isso inicia automaticamente o servidor HTTP pelo mesmo caminho de código do WebUI — sem ação do usuário:
private async ensureWebServerRunning(): Promise<void> { if (getWebServerInstance()) return; const instance = await startWebServerWithInstance(SERVER_CONFIG.DEFAULT_PORT, false); setWebServerInstance(instance);}JWT e ciclo de vida da instância
Seção intitulada “JWT e ciclo de vida da instância”O whatsapp-api retorna um JWT ao criar uma instância. Se ela já existe, a API retorna HTTP 400/403. fetchOrCreateJwt() lida com ambos os casos: em conflito, cai para GET /instance/fetchInstances para recuperar o JWT existente. O ThairaAI pode reiniciar livremente sem perder a sessão do whatsapp-api.
editMessage envia em vez de editar
Seção intitulada “editMessage envia em vez de editar”Todos os plugins de canal implementam editMessage para plataformas que suportam atualização de mensagens no lugar (ex: Telegram). O WhatsApp não tem API de edição.
WhatsAppPlugin.editMessage ignora todas as chamadas exceto quando message.replyMarkup está definido. O replyMarkup é o sinal de “stream completo” definido apenas na mensagem final pelo ActionExecutor. Chunks intermediários do stream são descartados silenciosamente; somente a resposta final da IA é entregue.
Para isso funcionar, ActionExecutor precisa definir replyMarkup na mensagem final. O código original fazia isso apenas para o WecomPlugin — 'whatsapp' foi adicionado ao mesmo guard:
const finalReplyMarkup = responseMarkup ?? (context.platform === 'wecom' || context.platform === 'whatsapp' ? ({ __aionuiFinal: true } as unknown) : undefined);Supressão de mensagens de status intermediárias
Seção intitulada “Supressão de mensagens de status intermediárias”O motor de streaming chama context.sendMessage (não editMessage) para eventos agent_status como ⏳ claude, que sinalizam qual backend de IA iniciou. Esses eventos apareceriam como mensagens separadas no WhatsApp.
WhatsAppPlugin rastreia um conjunto por chat (thinkingChats) para saber quando ⏳ Thinking... foi enviado mas a resposta final ainda não chegou. Qualquer mensagem adicional com prefixo ⏳ nessa janela é suprimida:
if (isStatusMsg && this.thinkingChats.has(chatId)) { return `whatsapp-suppressed-${Date.now()}`;}Suporte a imagens
Seção intitulada “Suporte a imagens”WhatsAppAdapter detecta imageMessage e cria um attachment com fileId apontando para a URL de download do whatsapp-api (/chat/public/image/download?q=...).
WhatsAppPlugin.handleInboundWebhook baixa a imagem para um arquivo .jpg temporário e grava o caminho local em attachments[0].fileName. normalizeMediaContent() então converte o conteúdo para uma mensagem de texto simples:
[Image: /tmp/wa-media-1234567890.jpg]Caption: o que há nessa foto?Esse texto é encaminhado à IA, que pode ler o arquivo usando suas ferramentas.
Transcrição de voz
Seção intitulada “Transcrição de voz”Mensagens de voz do WhatsApp chegam como OGG/Opus, que o whisper.cpp não decodifica diretamente. O pipeline converte para WAV antes da transcrição:
// 1. Converte OGG/Opus → WAV 16 kHz monoconst wavPath = await this.convertToWav(oggPath);
// 2. Transcreve via SpeechToTextService (qualquer provedor configurado)const result = await SpeechToTextService.transcribe({ audioBuffer: Array.from(fs.readFileSync(wavPath)), fileName: 'audio.wav', mimeType: 'audio/wav',});
// 3. Remove o WAV; encaminha transcrição à IAconvertToWav executa ffmpeg -ar 16000 -ac 1 -f wav. O .ogg original é mantido (já foi gravado no temp por downloadMediaToTemp); o .wav é deletado após a transcrição independentemente de sucesso ou falha.
Se a transcrição falhar por qualquer motivo, o plugin responde diretamente ao usuário com uma mensagem de erro específica em vez de encaminhar à IA:
| Código do erro | Mensagem enviada |
|---|---|
STT_DISABLED | ”Speech-to-Text está desabilitado. Ative em Configurações → Ferramentas.” |
STT_OPENAI_NOT_CONFIGURED | ”Chave da API OpenAI Whisper não configurada…” |
STT_DEEPGRAM_NOT_CONFIGURED | ”Chave da API Deepgram não configurada…” |
STT_REQUEST_FAILED:… | ”Transcrição falhou: …” (inclui detalhe do erro do servidor) |
| outro | ”Não foi possível transcrever a mensagem de voz: …” |
Provedor Whisper Local
Seção intitulada “Provedor Whisper Local”Um novo provedor 'local' foi adicionado ao SpeechToTextService. Ele chama um servidor HTTP compatível com whisper.cpp:
- Endpoint:
POST {url}/inference(formulário multipart comfile+languageopcional) - Resposta:
{ text: "..." } - URL padrão:
http://localhost:8080
LocalSpeechToTextConfig:
type LocalSpeechToTextConfig = { url?: string; // padrão: http://localhost:8080 language?: string; // código ISO 639-1, ex: "pt"};Configure em Configurações → Ferramentas → Fala para Texto → Whisper Local.
O build do whisper.cpp fica em whisper.cpp/ com o modelo ggml-base. O binário do servidor possui symlink em /Volumes/server-ssd/bin/whisper-server (que está no $PATH via ~/.zshrc).
Guard de tipo em lastMessageContent
Seção intitulada “Guard de tipo em lastMessageContent”O evento agent_status pode chegar após a resposta de texto no stream ACP. O ActionExecutor rastreava todo evento de stream como o “último conteúdo de mensagem”, então a chamada final de editMessage podia receber { text: '⏳ claude' } em vez da resposta real da IA — que era então suprimida, resultando em nenhuma entrega.
Corrigido atualizando lastMessageContent apenas para message.type === 'text':
if (message.type === 'text') { lastMessageContent = streamOutgoing;}Injeção de histórico de conversa
Seção intitulada “Injeção de histórico de conversa”As sessões de IA do ThairaAI vivem em memória. Após reinicialização ou 5 minutos de inatividade, a tarefa ACP em memória é removida. A próxima mensagem iniciaria um novo processo Claude Code sem contexto.
ChannelMessageService.sendMessage detecta uma tarefa nova (workerTaskManager.getTask() retornando undefined antes de getOrBuildTask) e consulta a tabela messages do SQLite próprio do ThairaAI para as últimas 30 trocas armazenadas. Essas são adicionadas como prefixo de contexto à mensagem do usuário:
[Previous conversation:User: Olá, tudo bem?Assistant: Tudo certo! O que precisa?]
<mensagem atual>Apenas mensagens type === 'text' são incluídas; placeholders ⏳ são filtrados. O histórico vem do SQLite interno do ThairaAI — sem dependência do Postgres do whatsapp-api.
yoloMode para WhatsApp
Seção intitulada “yoloMode para WhatsApp”'whatsapp' estava ausente da verificação isFromChannel no ChannelMessageService, fazendo o Claude Code aguardar aprovação manual de permissões na UI em vez de aprovar automaticamente. Adicionado ao lado de Telegram, Lark, DingTalk, WeChat e WeCom.
Scripts de inicialização concorrente
Seção intitulada “Scripts de inicialização concorrente”Dois scripts baseados em concurrently estão disponíveis para desenvolvimento:
# App + whatsapp-apibun run start:wa
# App + whatsapp-api + servidor whisperbun run start:wa:whisperstart:wa:whisper executa o whisper-server com o modelo base incluído:
whisper-server -m ../whisper.cpp/models/ggml-base.binOs três processos compartilham um único terminal com prefixos coloridos (app, wa-api, whisper). --kill-others-on-fail garante que todos os processos encerrem se qualquer um deles travar.
Modo Profissional (Business Mode)
Seção intitulada “Modo Profissional (Business Mode)”O Modo Profissional inverte o paradigma: em vez do proprietário conversar com seu próprio assistente, clientes externos mensagem o número WhatsApp do profissional e a IA responde em nome dele.
Configuração
Seção intitulada “Configuração”Armazenada em ConfigStorage sob a chave assistant.whatsapp.businessMode:
{ autoApprove: boolean; // habilita o modo businessName?: string; // ex: "Clínica Silva Psicologia" professionalName?: string; // ex: "Dra. Ana Silva" businessHours?: string; // ex: "Seg-Sex, 9h–18h" welcomeMessage?: string; // mensagem de boas-vindas personalizada excludedChats?: string[]; // chatIds com IA pausada (resposta manual do profissional)}A configuração é lida diretamente do ProcessConfig no processo principal — sem IPC adicional necessário.
Auto-aprovação
Seção intitulada “Auto-aprovação”PairingService.autoApproveUser() cria o registro em assistant_users sem exigir código de pareamento. Se o usuário já existe, retorna o existente (idempotente). Após a criação, emite channelBridge.userAuthorized para atualizar o painel.
Injeção de contexto profissional
Seção intitulada “Injeção de contexto profissional”ActionExecutor.buildBusinessContextForMessage() monta uma string de contexto com nome do consultório, profissional, horários, data/hora atual e um fluxo que ramifica conforme exista (ou não) uma agenda dedicada de atendimentos. Esse contexto é passado para ChannelMessageService.sendMessage() e prefixado em toda mensagem (não só na primeira), para a IA nunca perder a persona de recepcionista entre turnos em sessões retomadas:
[System context:ROLE: You are the WhatsApp receptionist for Dra. Ana Silva at Clínica Silva Psicologia.Business: Clínica Silva PsicologiaProfessional: Dra. Ana SilvaWorking hours: Seg-Sex, 9h–18h
CURRENT DATE & TIME: 2026-05-26T23:41:54.000Z (segunda-feira, 26 de maio de 2026 às 20:41,timezone America/Sao_Paulo).Use this as the reference for any relative date the client mentions ("amanhã", "próximasegunda", "daqui a 2 dias"). NEVER ask the client to confirm the year, weekday, or month.
CRITICAL — WHO YOU ARE TALKING TO:- You are NOT talking to Dra. Ana Silva. You are talking to a CLIENT (third party).- The Google Calendar belongs to Dra. Ana Silva, NOT to the client.- Refer to the professional in the third person; refer to the client as "você".
CALENDAR SCHEDULING WORKFLOW (with dedicated calendar configured):1. Call google_auth_status ONCE per turn (silent check).2. If the client asks about availability, call google_list_events on the dedicated appointment calendar — never the primary calendar.3. Collect details, then call google_create_event. The event title MUST include the client name/phone (e.g. "Consulta — João, 553197388888").4. Confirm in plain language; do NOT echo event IDs or technical output.
RESTRICTIONS (calendar configured):- Read AND write only the dedicated calendar; never touch the primary calendar/Gmail/contacts.- When listing events, only describe occupied slots in general terms ("esse horário não está disponível") — never reveal other clients' booking titles or attendees.- Never modify or delete existing events.]
<mensagem do cliente>Quando a agenda dedicada não está configurada, o prompt instrui a IA a não chamar google_list_events de forma alguma (ler a agenda primária vazaria dados privados).
ThairaGatewayAgent extrai o bloco [System context:…] via regex, guarda o texto capturado em this.systemPrompt e o adiciona como uma mensagem { role: 'system' } na requisição /v1/chat/completions compatível com OpenAI. O texto limpo do usuário (sem o wrapper) é o que vai para o histórico. Isso transforma a persona numa instrução formal de sistema, melhorando a aderência.
ThairaGatewayAgent
Seção intitulada “ThairaGatewayAgent”src/process/task/directAgent/ThairaGatewayAgent.ts é uma implementação leve de IAgentManager para sessões de recepcionista de clientes-empresa (tipo de conversa thaira-gateway). Ele não desabilita chamadas de ferramentas — em vez disso declara um conjunto mínimo e fixo de ferramentas de calendário e as executa em processo, sem CLI e sem subprocesso MCP. É o sucessor do removido GeminiDirectAgent: mesmo comportamento, mas o transporte é o gateway gerenciado da Thaira (compatível com OpenAI) em vez da API do Gemini, então toda resposta é medida.
Características:
- Sem subprocesso — faz streaming via
client.chat.completions.create({ stream: true })contra o gateway, em processo, usando o SDKopenai(new OpenAI({ baseURL: model.baseUrl, apiKey: idToken })). - Histórico em memória —
ChatMessage[](papéissystem/user/assistant/tool, incluindotool_callsdo assistente), limitado a 40 turnos, aparando para nunca começar numa mensagemtoolórfã. - Resolução de autenticação — busca um token Firebase novo via
cloudAuthService.getToken()a cada envio e o passa comoapiKey; sem token (deslogado) ou sem modelo selecionado, emite o marcadorGATEWAY_SIGN_IN_REQUIRED/GATEWAY_MODEL_NOT_SELECTEDe para. - Extração da instrução de sistema — a cada
sendMessage, remove o prefixo[System context:\n…\n], guarda o texto extraído emthis.systemPrompte o adiciona como mensagem{ role: 'system' }. O texto limpo do usuário é o que entra no histórico. - Function declarations fixas — toda requisição inclui
tools: CALENDAR_TOOLS_OPENAI(src/process/task/directAgent/calendarTools.ts) com três funções:google_auth_status,google_create_eventegoogle_list_events. O modelo não consegue chamar mais nada — não há MCP, shell, leitura de arquivo. - Laço multi-turn para ferramentas —
sendMessage()roda umwhile (continueLoop && toolTurns < MAX_TOOL_TURNS). A cada iteração faz streaming da resposta; quaisquertool_callssão executadas viaexecuteTool()(que despacha paragoogleIntegrationService.getStatus()/createEvent()/listEvents(), sendo que estas duas últimas usamgetAppointmentCalendarId()para lergoogle.integration.appointmentCalendarId, caindo para'primary'se vazio). Os resultados voltam para o histórico como mensagenstoole o laço continua até o modelo parar de chamar ferramentas.MAX_TOOL_TURNSé 5 para evitar loops infinitos; as tool calls são auto-executadas (sem confirmação interativa). - Persistência da mensagem do cliente — imediatamente após extrair o contexto, o agente chama
addMessage(conversation_id, { type: 'text', position: 'right', … })para que a mensagem recebida do cliente apareça no chat ao lado das respostas. - Visibilidade das tool calls — para cada chamada, o agente emite um evento
tool_groupduas vezes com o mesmotoolMsgId: primeiro comstatus: 'Executing'e descrição vinda dedescribeTool(), depois comstatus: 'Success'ou'Error'eresultDisplaycom o retorno da ferramenta. OcomposeMessagemescla por msg_id, então a UI mostra a transição de status ao vivo. - Emissão de eventos —
emit()encaminha cada evento viaemitAgentEvent(./emit), que faz streaming para a UI em tempo real e entrega a resposta no WhatsApp, e então persiste no SQLite.
Agenda dedicada de atendimentos
Seção intitulada “Agenda dedicada de atendimentos”Sessões de recepcionista precisam agendar sem vazar a agenda pessoal do profissional. O ThairaAI persiste o ID de uma Google Calendar em google.integration.appointmentCalendarId (com appointmentCalendarName para exibição); todas as ferramentas do recepcionista e o próprio prompt do sistema ramificam conforme isso esteja preenchido.
Ciclo de vida (GoogleIntegrationService):
createCalendar(summary)chamacalendar.calendars.insertcom o timezone local e retorna{ id, summary }.listCalendars()chamacalendarList.list({ minAccessRole: 'writer' }), garantindo que o picker mostre só agendas onde o usuário pode escrever.createEvent(params, calendarId = 'primary')elistEvents(maxResults, calendarId = 'primary')aceitamcalendarIdopcional — o default'primary'mantém o comportamento original para chamadores não-recepcionista.
UI de configurações (GoogleSettings.tsx):
O painel “Agenda de Atendimentos” só aparece quando status.connected é verdadeiro. Quando nenhuma agenda está configurada, mostra dois controles:
- “Criar agenda dedicada” — botão primário. Nome padrão
ThairaAI — Agendamentos (<professionalName>)se o campoprofessionalNamedo modo profissional estiver preenchido, senão sóThairaAI — Agendamentos. Persiste imediatamente emgoogle.integration. - “ou escolher existente…” Select — carrega as agendas com permissão de escrita sob foco (
loadAvailableCalendars()cacheia o resultado para fazer só um IPC por sessão). Ordem: nomes com “ThairaAI”/“Agendamento” primeiro, depoisprimary, depois alfabética. Selecionar uma persiste imediatamente. Útil para quem já tinha uma agenda dedicada de outra ferramenta, ou quer testar com a agenda pessoal.
Quando uma agenda já está configurada, o painel colapsa para o nome dela mais um botão Limpar.
Resolução no lado do agente (getAppointmentCalendarId() em calendarTools.ts):
async function getAppointmentCalendarId(): Promise<string> { const cfg = await ProcessConfig.get('google.integration'); const id = cfg?.appointmentCalendarId?.trim(); return id && id.length > 0 ? id : 'primary';}Chamado dentro de executeTool() tanto em google_create_event quanto em google_list_events. Se nenhuma agenda estiver configurada, google_list_events retorna uma string avisando o modelo que não há agenda — em vez de despejar o conteúdo da agenda primária. Defesa em profundidade sobre a restrição no nível do prompt.
Restrição no prompt (ActionExecutor.buildBusinessContextForMessage()):
O system prompt lê google.integration.appointmentCalendarId e ramifica:
- Configurada: instrui a IA a chamar
google_list_eventspara perguntas sobre disponibilidade, descrever horários ocupados em termos genéricos, nunca revelar títulos de agendamentos de terceiros, e gravar todos os eventos novos na agenda dedicada. - Não configurada: proíbe explicitamente
google_list_events; a IA só pode chamargoogle_auth_statusegoogle_create_event.
Sessões do proprietário / ACP:
A engenharia de recepcionista acima só dispara para sessões role: 'client' (o contato WhatsApp falando com a recepcionista). O proprietário — ou seja, o profissional conversando com seu próprio assistente — segue o caminho ACP padrão com toda a ferramentaria do Claude Code, e acessa a agenda via o MCP server builtin aionui-google-integration.
Esse MCP server é um subprocesso stdio lançado a partir da entrada em mcp.config (persistida em aionui-config.txt). Suas ferramentas google_list_events e google_create_event leem process.env.APPOINTMENT_CALENDAR_ID (via getDefaultCalendarId()) e usam como calendarId padrão, com parâmetro opcional calendar_id para opt-out (ex.: calendar_id: 'primary' para consultar a agenda pessoal do profissional). A env var é populada por buildGoogleEnv() em initStorage.ts, que lê google.integration.appointmentCalendarId do arquivo de config no momento do spawn.
Quando o usuário cria ou seleciona uma agenda em Configurações → Integrações → Google, o renderer chama um dos dois métodos IPC do processo principal (googleIntegration.createCalendar ou googleIntegration.setAppointmentCalendar). Ambos:
- Persistem o novo valor em
google.integrationnoaionui-config.txt - Chamam
refreshGoogleMcpEnv(), que re-grava o bloco env da entradaaionui-google-integrationemmcp.config
Subprocessos MCP recém-lançados pegam o novo APPOINTMENT_CALENDAR_ID automaticamente. Sessões Claude em andamento mantêm a env original até o subprocesso MCP reiniciar — tipicamente na próxima mensagem após algum tempo ocioso, ou após o usuário reiniciar o agente.
Resultado: quando o Dr. manda mensagem para si mesmo (“quais consultas eu tenho hoje?”), o Claude chama google_list_events, o MCP usa a agenda dedicada (não a primária), e o Dr. vê apenas os agendamentos feitos pela recepcionista — sem expor a agenda pessoal ao LLM.
Notificação ao proprietário
Seção intitulada “Notificação ao proprietário”Ao ativar ou desativar o Modo Profissional pelo pill na tela inicial, o app envia uma mensagem para o próprio número do proprietário via channel.notify-business-mode IPC:
channelBridgebusca o plugin WhatsApp ativoWhatsAppPlugin.getOwnerPhone()chamaGET /instance/fetchInstancese extrai oownerJid- Envia a mensagem de status diretamente para esse número
Exclusão por chat (pausando a IA para um contato)
Seção intitulada “Exclusão por chat (pausando a IA para um contato)”excludedChats é uma lista de JIDs do WhatsApp (5511999@s.whatsapp.net) onde a IA deve ficar silenciosa e deixar o profissional responder manualmente.
A exclusão é verificada em dois pontos em ActionExecutor.handleIncomingMessage:
- Caminho de novo contato —
getBusinessModeConfig(platform, chatId)retornanullsechatIdestiver emexcludedChats, então a auto-aprovação é ignorada e o fluxo de pareamento é exibido. - Caminho de usuário autorizado — após a verificação de autorização, um guard dedicado lê a config bruta e retorna cedo (descarta a mensagem silenciosamente) se o chatId estiver excluído:
if (isAuthorized && platform === 'whatsapp' && chatId) { const bm = await ProcessConfig.get('assistant.whatsapp.businessMode'); if (bm?.excludedChats?.includes(chatId)) return;}Três formas de alternar a exclusão:
| Método | Quem | Onde |
|---|---|---|
| Toggle de UI | Profissional | Cabeçalho da conversa (Switch renderizado por WhatsAppAgentToggle) |
| Lista de configurações | Admin | Configurações → Canais → WhatsApp → lista de usuários autorizados (Switch por usuário) |
| Comando no chat | Cliente ou profissional no chat do WhatsApp | /pause para adicionar, /resume para remover |
Preset de recepcionista
Seção intitulada “Preset de recepcionista”src/process/resources/assistant/therapy-receptionist/therapy-receptionist.md define um assistente especializado em:
- Verificar autenticação do Google Calendar antes de qualquer ação
- Listar eventos, identificar slots livres e oferecer opções ao cliente
- Criar agendamentos somente após confirmação explícita
- Nunca oferecer orientação clínica ou psicológica
- Responder no idioma do cliente
Skill WhatsApp
Seção intitulada “Skill WhatsApp”A Skill WhatsApp permite ao agente desktop (janela principal do ThairaAI) enviar mensagens e buscar histórico recente de qualquer contato do WhatsApp sob demanda. Usa o mesmo padrão de blocos de protocolo + middleware das skills cron e weixin-file-send.
Blocos de protocolo
Seção intitulada “Blocos de protocolo”A IA emite um ou mais blocos dentro do texto de resposta:
[WA_SEND]{"number":"5511999999999","text":"Vou me atrasar"}[/WA_SEND][WA_FETCH]{"number":"5511999999999","limit":10}[/WA_FETCH][WA_SEND]— fire-and-forget. Processado após o turno da IA. Emite mensagem de sistema✅ Enviado para {number}.[WA_FETCH]— dois passos. O runtime busca mensagens recentes do contato, injeta como mensagem de sistema e a IA continua com esse contexto (mesmo padrão do[CRON_LIST]).
Ativação da skill
Seção intitulada “Ativação da skill”src/process/resources/skills/whatsapp/SKILL.md tem um gatilho no frontmatter:
---name: whatsappdescription: | Use quando o usuário quiser enviar uma mensagem WhatsApp ou ler mensagens recentes de um contato. Requer WhatsApp conectado.---O arquivo é carregado automaticamente quando o usuário pergunta sobre mensagens do WhatsApp. Contém sintaxe, descrição de campos e exemplos para os dois blocos.
Pipeline de execução
Seção intitulada “Pipeline de execução”Após cada turno da IA, os gerenciadores com ferramentas (AcpAgentManager, AionrsManager) chamam processWaInMessage se hasWaCommands for verdadeiro:
Texto de resposta da IA → hasWaCommands() → true → detectWaCommands() → [{ kind: 'send', number, text }, ...] → processWaInMessage() para cada comando: send → getActiveWhatsAppPlugin().sendMessage(jid, { type:'text', text }) emite "✅ Enviado para {number}" fetch → plugin.fetchMessages(jid, limit) formata mensagens como lista legível emite resultado como mensagem de sistema → IA continua sem plugin → emite "⚠️ WhatsApp não está conectado."fetchMessages
Seção intitulada “fetchMessages”WhatsAppPlugin.fetchMessages(remoteJid, limit) normaliza números simples para JIDs completos (5511999@s.whatsapp.net) antes de consultar:
GET {serverUrl}/chat/findMessages/{instanceName}?remoteJid={jid}&count={limit}Authorization: Bearer {jwtToken}Retorna WaFetchedMessage[]:
type WaFetchedMessage = { from: string; text: unknown; timestamp: number };