Pular para o conteúdo

Arquitetura do Canal WhatsApp

Esta página cobre o design interno da integração WhatsApp para contribuidores.

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ário
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 texto
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 texto
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]
Usuário desconhecido envia mensagem
→ ActionExecutor.handleIncomingMessage()
→ handlePairingShow() # exibe código de 6 dígitos no WhatsApp
→ aguarda aprovação manual no painel

Contato 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 manualmente

Novos arquivos:

ArquivoPapel
src/process/channels/plugins/whatsapp/WhatsAppAdapter.tsConversã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.tsPlugin 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.tsRe-exporta plugin + acessores singleton (getActiveWhatsAppPlugin).
src/process/webserver/routes/whatsappChannelRoutes.tsRota 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.mdPreset de assistente recepcionista profissional com agendamento via Google Calendar.
src/process/resources/skills/whatsapp/SKILL.mdSkill 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.tsInterpreta blocos [WA_SEND] e [WA_FETCH] no output da IA. Exporta detectWaCommands, hasWaCommands, stripWaCommands.
src/process/task/WhatsAppMiddleware.tsExecuta os comandos WhatsApp: chama sendMessage ou fetchMessages no plugin ativo e emite uma resposta de sistema de volta à IA.
src/process/channels/actions/WhatsAppPlatformActions.tsHandlers dos comandos /pause e /resume no chat. Adiciona/remove o chatId de excludedChats na config.
src/renderer/pages/conversation/components/WhatsAppAgentToggle.tsxSwitch por chat renderizado no cabeçalho da conversa. Lê e grava excludedChats via ConfigStorage direto do renderer.
src/renderer/pages/conversation/hooks/WhatsAppWebContext.tsxContexto 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:

ArquivoMudança
src/process/channels/plugins/whatsapp/WhatsAppAdapter.tsAdicionado preenchimento de attachments para tipos de mensagem photo, voice, audio, document.
src/process/channels/plugins/whatsapp/WhatsAppPlugin.tsAdicionado tipo WaFetchedMessage e método fetchMessages(remoteJid, limit) — chama GET /chat/findMessages/{instanceName} na API CodeChat.
src/common/types/speech.tsAdicionado 'local' a SpeechToTextProvider. Novo LocalSpeechToTextConfig { url?, language? }. Campo local? adicionado a SpeechToTextConfig.
src/process/bridge/services/SpeechToTextService.tsAdicionado transcribeWithLocal() — envia áudio multipart para um servidor compatível com whisper.cpp via HTTP.
src/renderer/components/settings/SettingsModal/contents/ToolsModalContent.tsxOpçã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.jsonAdicionados speechToTextProviderLocal, speechToTextLocalUrl e labels do toggle por usuário no Modo Profissional.
src/renderer/services/i18n/locales/pt-BR/settings.jsonMesmas adições em português.
src/process/channels/types.tsAdicionado 'whatsapp' a BuiltinPluginType, isBuiltinChannelPlatform, hasPluginCredentials e getChannelConversationName. Campos do Modo Profissional em IPluginConfigOptions.
src/process/channels/core/ChannelManager.tsPlugin registrado; adicionado a builtinStartableTypes; extração de credenciais; resolução de nome/ID.
src/process/webserver/routes/apiRoutes.tsChama registerWhatsAppChannelRoutes(app).
src/process/webserver/setup.tsAdicionado /channels/whatsapp/webhook à lista de exclusão CSRF.
src/process/channels/gateway/ActionExecutor.tsCorreçõ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.tsInjeção de histórico + contexto profissional + correção de yoloMode.
src/process/channels/pairing/PairingService.tsNovo método autoApproveUser() para aprovação sem código.
src/common/config/storage.tsassistant.whatsapp.businessMode atualizado com campo excludedChats?: string[].
src/process/bridge/channelBridge.tsHandler IPC channel.notify-business-mode.
src/common/adapter/ipcBridge.tsMétodo channel.notifyBusinessMode exposto ao renderer.
src/renderer/components/settings/SettingsModal/contents/channels/WhatsAppConfigForm.tsxSeção “Modo Profissional” com toggle, campos de configuração e Switch de exclusão por usuário autorizado.
src/renderer/components/media/WebviewHost.tsxAdicionada prop userAgent?: string; conectada ao atributo useragent do <webview> do Electron quando fornecida.
src/renderer/main.tsxAdicionado WhatsAppWebProvider na cadeia global de AppProviders para montar o webview singleton uma única vez na raiz do app.
src/renderer/pages/conversation/components/ChatConversation.tsxAdicionado 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.tsxPill de toggle rápido do Modo Profissional na tela inicial.
src/process/task/AcpAgentManager.tsApós resposta da IA, detecta blocos WA e chama processWaInMessage.
src/process/task/GeminiAgentManager.tsIdem.
src/process/task/AionrsManager.tsIdem.
package.jsonAdicionada 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 é o ThairaGatewayAgent (src/process/task/directAgent/ThairaGatewayAgent.ts, tipo de conversa thaira-gateway), o agente interativo do dono/desktop é o AionrsManager, e o conjunto de ferramentas de calendário passou para src/process/task/directAgent/calendarTools.ts. Onde as seções abaixo citam uma classe Gemini, leia como seu equivalente atual.

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: hidden em vez de display: none — O div do slot deve sempre ter position: absolute; inset: 0 com visibility: hidden/visible. Usar display: none colapsa o elemento, fazendo getBoundingClientRect() retornar zeros e causando o desaparecimento do webview.
  • Sem key no WhatsAppConversationPanel — Sem a prop key, 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 userAgent no WebviewHost.
  • Border radiusgetComputedStyle(slotEl).borderRadius é lido no momento da medição e aplicado ao container fixo com overflow: hidden, fazendo o webview recortar com as bordas arredondadas do painel.

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);
}

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.

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);

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()}`;
}

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.

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 mono
const 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 à IA

convertToWav 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 erroMensagem 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: …”

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 com file + language opcional)
  • 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).

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;
}

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.

'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.

Dois scripts baseados em concurrently estão disponíveis para desenvolvimento:

Terminal window
# App + whatsapp-api
bun run start:wa
# App + whatsapp-api + servidor whisper
bun run start:wa:whisper

start:wa:whisper executa o whisper-server com o modelo base incluído:

whisper-server -m ../whisper.cpp/models/ggml-base.bin

Os 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.


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.

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.

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.

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 Psicologia
Professional: Dra. Ana Silva
Working 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óxima
segunda", "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.

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 SDK openai (new OpenAI({ baseURL: model.baseUrl, apiKey: idToken })).
  • Histórico em memóriaChatMessage[] (papéis system/user/assistant/tool, incluindo tool_calls do assistente), limitado a 40 turnos, aparando para nunca começar numa mensagem tool órfã.
  • Resolução de autenticação — busca um token Firebase novo via cloudAuthService.getToken() a cada envio e o passa como apiKey; sem token (deslogado) ou sem modelo selecionado, emite o marcador GATEWAY_SIGN_IN_REQUIRED / GATEWAY_MODEL_NOT_SELECTED e para.
  • Extração da instrução de sistema — a cada sendMessage, remove o prefixo [System context:\n…\n], guarda o texto extraído em this.systemPrompt e 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_event e google_list_events. O modelo não consegue chamar mais nada — não há MCP, shell, leitura de arquivo.
  • Laço multi-turn para ferramentassendMessage() roda um while (continueLoop && toolTurns < MAX_TOOL_TURNS). A cada iteração faz streaming da resposta; quaisquer tool_calls são executadas via executeTool() (que despacha para googleIntegrationService.getStatus() / createEvent() / listEvents(), sendo que estas duas últimas usam getAppointmentCalendarId() para ler google.integration.appointmentCalendarId, caindo para 'primary' se vazio). Os resultados voltam para o histórico como mensagens tool e 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_group duas vezes com o mesmo toolMsgId: primeiro com status: 'Executing' e descrição vinda de describeTool(), depois com status: 'Success' ou 'Error' e resultDisplay com o retorno da ferramenta. O composeMessage mescla por msg_id, então a UI mostra a transição de status ao vivo.
  • Emissão de eventosemit() encaminha cada evento via emitAgentEvent (./emit), que faz streaming para a UI em tempo real e entrega a resposta no WhatsApp, e então persiste no SQLite.

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) chama calendar.calendars.insert com o timezone local e retorna { id, summary }.
  • listCalendars() chama calendarList.list({ minAccessRole: 'writer' }), garantindo que o picker mostre só agendas onde o usuário pode escrever.
  • createEvent(params, calendarId = 'primary') e listEvents(maxResults, calendarId = 'primary') aceitam calendarId opcional — 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 campo professionalName do modo profissional estiver preenchido, senão só ThairaAI — Agendamentos. Persiste imediatamente em google.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, depois primary, 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_events para 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 chamar google_auth_status e google_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:

  1. Persistem o novo valor em google.integration no aionui-config.txt
  2. Chamam refreshGoogleMcpEnv(), que re-grava o bloco env da entrada aionui-google-integration em mcp.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.

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:

  1. channelBridge busca o plugin WhatsApp ativo
  2. WhatsAppPlugin.getOwnerPhone() chama GET /instance/fetchInstances e extrai o ownerJid
  3. 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:

  1. Caminho de novo contatogetBusinessModeConfig(platform, chatId) retorna null se chatId estiver em excludedChats, então a auto-aprovação é ignorada e o fluxo de pareamento é exibido.
  2. 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étodoQuemOnde
Toggle de UIProfissionalCabeçalho da conversa (Switch renderizado por WhatsAppAgentToggle)
Lista de configuraçõesAdminConfigurações → Canais → WhatsApp → lista de usuários autorizados (Switch por usuário)
Comando no chatCliente ou profissional no chat do WhatsApp/pause para adicionar, /resume para remover

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

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.

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]).

src/process/resources/skills/whatsapp/SKILL.md tem um gatilho no frontmatter:

---
name: whatsapp
description: |
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.

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."

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 };