Dossiê: Wuzapi
- Docs oficiais: https://github.com/asternic/wuzapi/blob/main/API.md
- Versão testada: documentação/código consultados em 2026-07-11 (branch
maindo repositórioasternic/wuzapi; sem tag de release fixada pelo próprio projeto no momento da pesquisa) - Hospedagem: self-hosted (Docker;
docker-compose.ymledocker-compose-swarm.yamlno repo). Banco padrão é SQLite sem configuração nenhuma; PostgreSQL é opcional viaDB_HOST/DB_USER/DB_PASSWORD/DB_NAME/DB_PORT/DB_SSLMODE.
Base técnica: assim como o Evolution GO (F1), o Wuzapi é construído sobre a mesma biblioteca Go
tulir/whatsmeow. Vários mapeamentos de conteúdo de mensagem (conversation,extendedTextMessage,imageMessage, ...) e de nomes de evento de conexão (Connected/Disconnected/LoggedOut/PairSuccess/...) foram inferidos por analogia com o adapter Evolution GO (src/adapters/evolution/index.ts) quando a pesquisa original não trazia um exemplo literal equivalente para o Wuzapi — o dossiê de pesquisa não consultou o código-fonte da própria libwhatsmeow, então esses pontos são plausíveis, não confirmados campo-a-campo. Cada ocorrência está sinalizada abaixo.
Autenticação
Dois esquemas de token distintos, nenhum com prefixo Bearer (valor cru no header):
- Nível usuário/sessão — header
token(ohttp.Header.Getdo Go é case-insensitive, entãoToken/tokensão equivalentes), com fallback para query string?token=se o header vier vazio. É o único usado por todas as capabilities implementadas nesta fase (F2). - Nível admin (rotas
/admin/**, ex.POST /admin/users) — headerAuthorizationcom o valor deWUZAPI_ADMIN_TOKEN, comparado por SHA-256 +subtle.ConstantTimeCompare(tempo constante). Nenhuma capability implementada nesta fase usaAuthorization/admin token — a criação do usuário/token (POST /admin/users) é pré-requisito operacional (feito fora do waconector, por quem administra o servidor), não uma capability do contratoWaAdapter.
⚠️ Divergência confirmada entre a prosa da doc e o código-fonte:
API.md(linhas 5, 11) e oREADME.md(linhas 238, 311) afirmam em prosa que todas as requisições (inclusive de usuário) usam o headerAuthorization. Isso é falso para rotas de usuário —handlers.go(funcauthalice, linha ~155) faztoken := r.Header.Get("token")explicitamente, e os próprios exemplos decurldentro do mesmoAPI.mdusam-H 'Token: 1234ABCD'(ex.: linhas 154, 352, 389, 415, 444, 473, 501, 823 etc.) — a doc se autocontradiz entre prosa e exemplos, e o exemplo bate com o código. Este adapter usa o headertoken, confiando no código e nos exemplos decurl, não na prosa.
WuzapiOptions.token é o token de usuário (definido pelo próprio admin ao criar o usuário via POST /admin/users, campo token no body — não é autogerado pelo servidor). WuzapiOptions.adminToken existe apenas para permitir guardar os dois segredos num único lugar (e redigi-los em erros via secrets do HttpClient) — reservado para uma fase futura que exponha provisionamento de usuário (POST /admin/users, fora do contrato WaAdapter atual). Mesmo padrão já usado em UazapiOptions.adminToken.
Modelo de instância/sessão
Não existe o termo "instance" no domínio do Wuzapi. A entidade central é o "user" (tabela users: id, name, token, webhook, jid, events, proxy_url, qrcode, history, s3_*, hmac_key), gerenciado via /admin/users. Cada "user" abre uma "session" do WhatsApp (conexão websocket via whatsmeow) através de POST /session/connect. Ou seja: user = identidade/ credencial de longo prazo (1 token = 1 número de WhatsApp); session = estado de conexão volátil dessa identidade.
- Criar usuário (fora do escopo desta fase — exige
Authorization: WUZAPI_ADMIN_TOKEN):POST /admin/users, body{name, token (obrigatório, definido pelo chamador), webhook?, events?, expiration? (aceito mas NÃO enforced pelo sistema, segundo o próprio README), proxyConfig?/ s3Config?/hmacKey?/history?}. Oidinterno é autogerado (GenerateRandomID()). Só cria o registro — não conecta ao WhatsApp. - Conectar:
POST /session/connect(headertoken). Body{"Subscribe":["Message","ReadReceipt",...],"Immediate":true|false}. Dispara a conexão whatsmeow; se não houver sessão pareada, um QR code é gerado internamente (recuperável viaGET /session/qrou via evento de webhookQR). SeImmediateforfalse, a chamada bloqueia por até 10s para validar o login antes de responder — o adapter sempre enviaImmediate: truepor padrão (configurável viaWuzapiOptions.immediate) para não bloquear a chamada por padrão. - QR code:
GET /session/qr(headertoken) só retorna sucesso se a sessão estiverconnectede ainda nãologgedIn— se já estiver logada, responde com um erro"already logged in". O valor deQRCodejá vem com o prefixo completodata:image/png;base64,embutido. O adapter chama esse endpoint best-effort logo apósPOST /session/connect(mesmo padrão do adapter Evolution GO): se falhar (sessão ainda nãoconnected, ou jáloggedIn),ConnectResult.qrficaundefinedsem lançar. - Status:
GET /session/status(headertoken). Odatado envelope inclui, no mínimo (confirmado emhandlers.go, funcGetStatus):id,name,connected(bool),loggedIn(bool),token,jid,webhook,events,proxy_url,qrcode,history,proxy_config,s3_config,hmac_configured.⚠️ Divergência confirmada: o exemplo minimalista do próprio
API.mdmostra apenas{"Connected":true,"LoggedIn":true}com chaves capitalizadas. As chaves reais no wire sãoconnected/loggedInminúsculas (confirmado no código-fonte, não só na prosa) — o adapter lê as chaves minúsculas como fonte primária, com fallback defensivo para as capitalizadas do exemplo da doc, caso uma versão futura do servidor volte a usá-las. - Logout (hard):
POST /session/logout— desconecta e invalida a sessão no WhatsApp (whatsmeow Logout()); a próxima conexão exige novo QR/pairing. É este endpoint queinstance.logout()usa (semântica de "logout" = exige novo pareamento, igual ao WAHA/uazapi). - Disconnect (soft, não usado por este adapter):
POST /session/disconnect— só fecha o websocket preservando as credenciais da sessão (reconectar depois não pede novo QR); aceita?clear=truepara também limpar as inscrições de eventos. Distinto delogout— fora do escopo deInstanceApi.logout(). - Pairing code:
POST /session/pairphone, body{"Phone":"..."}, resposta{"LinkingCode":"..."}. Existe no código e está registrado emroutes.go, mas não é documentado em nenhum lugar doAPI.mdatual (descoberto só lendohandlers.go).instance.pairingCodenão é declarada nesta fase:InstanceApi.connect()não recebe telefone como parâmetro no contrato atual, então expor esse fluxo exigiria mudar o contrato central — fora do escopo desta fase (mesma decisão já tomada nos adapters WAHA/uazapi).
Mapeamento de estado (GET /session/status → InstanceState canônico)
connected | loggedIn | Significado do provider | InstanceState canônico |
|---|---|---|---|
false | false | nunca conectada / deslogada | disconnected |
true | false | websocket ativo, aguardando escanear QR | qr |
true | true | conectada e autenticada (sessão ativa) | connected |
false | true | credenciais existem, socket caiu temporariamente | connecting (suposição, mesmo raciocínio já documentado no dossiê do Evolution GO — o provider não expõe um endpoint de "reconnect" dedicado para este adapter usar como confirmação) |
| qualquer valor não-booleano/ausente | — | formato inesperado | unknown (nunca lança) |
Capabilities implementadas nesta fase (F2)
instance.connect, instance.status, instance.logout, messages.sendText, messages.sendMedia, messages.sendReaction, groups.create, groups.getInfo, groups.list, groups.addParticipants, groups.removeParticipants, groups.promoteParticipants, groups.demoteParticipants, groups.updateSubject, groups.updateDescription, groups.updatePicture, groups.getInviteLink, groups.revokeInviteLink, groups.joinViaInviteLink, groups.leaveGroup, contacts.list, contacts.get, contacts.checkExists, contacts.getProfilePicture, contacts.getAbout, contacts.block, contacts.unblock, contacts.listBlocked, webhooks.parse.
instance.pairingCode não foi declarada (ver justificativa acima).
Capabilities implementadas nesta fase (ADR-0012: edição/exclusão de mensagem + chats.*; ADR-0013: ações sobre mensagem; ADR-0014: conteúdo estruturado)
messages.edit, messages.delete, chats.archive, chats.unarchive (ADR-0012); messages.markRead (ADR-0013 — sem messages.forward/star/pin/unpin, ver seção dedicada abaixo); messages.sendLocation/sendContactCard/sendPoll (ADR-0014 — cobertura 3/3, ver seção "Conteúdo estruturado" abaixo).
⚠️ Nota de metodologia: nesta rodada, o relatório de pesquisa dedicado (1 agente por provider, mesma metodologia das rodadas anteriores — ver ADR-0012) não estava disponível para o Wuzapi no momento da implementação. As seções abaixo ("Edição e exclusão de mensagem", "Conversas (
chats.*)") foram verificadas diretamente contra o código-fonte público deasternic/wuzapi(routes.go,handlers.go,wmiau.go; branchmain, verificado em 2026-07-12) em vez de a partir daquele relatório — mesmo padrão de citação de fonte primária já usado no resto deste dossiê, só que sem o relatório intermediário.Isso também corrige uma leitura possível do achado registrado em ADR-0012 ("Wuzapi não tem NENHUMA das 8 operações candidatas exceto
archive— busca exaustiva em código sem resultado para mute/pin/markRead/markUnread"): a verificação direta confirma quechats.unarchivetambém está coberta (mesmo endpoint dearchive, variando um booleano — ver seção dedicada abaixo), e quePOST /chat/markreadexiste no código, ainda que não sirva ao contrato canônicochats.markRead(chatId)(ver justificativa na mesma seção). O achado de ADR-0012 permanece correto paramute/unmute/pin/unpin(nenhum endpoint equivalente encontrado) e paramarkUnread(nenhum endpoint, nem incompatível nem compatível — simplesmente não existe).
Edição e exclusão de mensagem
| Operação canônica | Endpoint | Observações |
|---|---|---|
messages.edit | POST /chat/send/edit | Corpo {Phone, Body, Id}. Ver subseção dedicada abaixo. |
messages.delete | POST /chat/delete | Corpo {Phone, Id}. Ver subseção dedicada abaixo. |
messages.markRead (ADR-0013) | POST /chat/markread | Corpo {Id: string[], ChatPhone} — nível de MENSAGEM. Este é exatamente o endpoint que a nota de metodologia acima já tinha identificado como "existe, mas não serve a chats.markRead" — implementado agora como capability de nível de mensagem. SenderPhone (opcional, só necessário em grupos) não é enviado. Id é rejeitado com 400 se vazio (len(t.Id) < 1); este adapter sempre envia um array com 1 elemento. Campos legados Chat/Sender não são usados — ChatPhone/SenderPhone são os "novos campos padronizados e priorizados" segundo o comentário do próprio código-fonte. Sem messages.forward/star/pin/unpin: busca exaustiva em handlers.go (7525 linhas) não encontrou nenhuma ocorrência de Forward/Star/Pin/pinned/BuildPin — limitação real do provider, não gap de pesquisa. |
messages.edit — POST /chat/send/edit
Confirmado no código-fonte (handlers.go, func SendEditMessage; registrado em routes.go). Corpo: {Phone, Body, Id} — Phone recebe o mesmo tratamento de sendText/sendReaction (toWuzapiPhone, identidade — o servidor usa o mesmo parseJID lenient, aceitando dígitos crus ou JID explícito); Body é o novo texto (reaproveita o mesmo nome de campo de sendText); Id é o id da mensagem original a editar (EditMessageInput.messageId).
Internamente o handler monta um waE2E.Message{ExtendedTextMessage:{Text: &Body}} e chama BuildEdit(recipient, msgid, msg) do whatsmeow, via SendMessage — é uma edição real, não um reenvio. Sem validação de janela de tempo no código (nenhum prazo é checado no servidor — um eventual limite de ~15min do WhatsApp real, se existir, só se manifestaria como erro HTTP em runtime, nunca validado client-side — mesma conclusão da ADR-0012 para os demais providers pesquisados). O struct do servidor também aceita um ContextInfo opcional (StanzaID/ Participant/MentionedJID) — não exposto por EditMessageInput (sem campo canônico equivalente), então não é enviado; a ausência não impede a edição (o campo é opcional no decode).
Resposta confirmada: mesmo envelope {"Details":"Sent","Timestamp":<unix seconds>,"Id":"<id>"} de sendText/sendReaction — aqui Id é o eco do id requisitado (editar não gera um novo id de mensagem). Mapeado com a mesma mapSentMessage já usada pelas demais operações de envio, sem alteração.
messages.delete — POST /chat/delete
Confirmado no código-fonte (handlers.go, func DeleteMessage; registrado em routes.go). Corpo: {Phone, Id} — mesmo tratamento de Phone via toWuzapiPhone (identidade).
Internamente o handler chama BuildRevoke(recipient, types.EmptyJID, msgid) do whatsmeow — o segundo parâmetro vazio (types.EmptyJID) é a convenção do whatsmeow para "revogar uma mensagem que a própria sessão enviou" (só é possível revogar mensagens próprias). Sempre revogação para todos os participantes, nunca um "apagar só localmente" — não há nenhum campo de escopo no corpo aceito pelo servidor, coerente com DeleteMessageInput do contrato canônico (sem campo de escopo — ver ADR-0012). Mesmo mecanismo (BuildRevoke, protocolo real do whatsmeow) citado na própria ADR como confirmado "a fundo" para este provider.
Resposta confirmada: {"Details":"Deleted","Timestamp":<unix seconds>,"Id":"<id>"} (Id = eco do id requisitado) — inteiramente ignorada, contrato retorna Promise<void>.
Conteúdo estruturado (messages.sendLocation/sendContactCard/sendPoll, ADR-0014)
Cobertura 3/3 — confirmado no API.md e em código-fonte (handlers.go).
| Operação canônica | Endpoint | Observações |
|---|---|---|
messages.sendLocation | POST /chat/send/location | Corpo {Phone, Name?, Latitude, Longitude}. Nuance documentada no próprio código: a validação de obrigatoriedade é if t.Latitude == 0 { ... }/if t.Longitude == 0 { ... } (handlers.go:1922-1928) — o servidor não distingue "campo ausente" de "0.0 exato"; uma localização real no equador/meridiano de Greenwich (0.0) seria rejeitada como "faltando" (edge case real do provider, não deste adapter). Resposta: mesmo envelope {Details,Timestamp,Id} de sendText. |
messages.sendContactCard | POST /chat/send/contact | Corpo {Phone, Name (obrigatório), Vcard (obrigatório, string vCard 3.0 completa)} — este provider não monta o vCard a partir de campos soltos (diferente de Evolution/uazapi/Z-API): Vcard precisa ser uma string já formatada pelo chamador (o próprio exemplo do API.md mostra um vCard inteiro escapado com \n). SendContactCardInput só expõe contactName/contactPhone soltos — este adapter monta a string vCard mínima localmente (buildVcard: FN:{name} + TEL;type=CELL;type=VOICE;waid={phone}:+{phone}, mesmo formato que a Evolution confirma gerar server-side). |
messages.sendPoll | POST /chat/send/poll | Confiança Alta no endpoint/código-fonte (handlers.go:2735-2815), Média no formato de "capability" canônica — não documentado no API.md. Corpo com tags JSON MINÚSCULAS (diferente do padrão PascalCase-sem-tag do resto da API): {group, header, options} (req.Group/req.Header/req.Options, obrigatórios; len(req.Options) < 2 é rejeitado). Apesar do nome do campo, group passa por validateMessageFields(req.Group, nil, nil) — o mesmo parser genérico de Phone usado em mensagens 1:1 — então aceita qualquer JID (indivíduo ou grupo), não só @g.us. allowMultipleAnswers é ignorado: BuildPollCreation(req.Header, req.Options, 1) — o último argumento (selectableOptionsCount) é HARDCODED em 1 (handlers.go:2789) — só enquetes de escolha única são suportadas, sem exceção (limitação real do provider). Resposta diferente do resto da API: {"Details":"Poll sent successfully","Id":"<msgid>"} — sem Timestamp (handlers.go:2807). O servidor guarda as opções em texto plano num cache interno (SetPollOptions) para decodificar votos recebidos depois — estado do lado do provider, não do waconector. |
Presença (presence.setTyping/set/subscribe, ADR-0015)
Cobertura 3/3, confiança Alta para as 3 — a MELHOR cobertura da fila junto com WAHA/Whapi.
| Operação canônica | Endpoint | Observações |
|---|---|---|
presence.setTyping | POST /chat/presence | Documentado no API.md (linhas 996-1006) e no código (handlers.go:3783-3839). Corpo: {Phone, State, Media} — o enum State só tem "composing"/"paused", sem valor dedicado a "gravando áudio"; em vez disso, Media: "audio" (junto com State: "composing") indica gravação de áudio ("if media is set to 'audio' it will indicate an audio message is being recorded"). TypingState.recording mapeia para State: "composing" + Media: "audio". |
presence.set | POST /user/presence | Código confirmado (handlers.go:3387-3440), não documentado no API.md. Corpo: {type: "available"|"unavailable"} — só esses dois valores são aceitos, qualquer outro retorna 400. Presença GLOBAL da conta, distinta da presença por-chat acima. Resposta: {"Details":"Presence set successfuly"} (erro de digitação "successfuly" no próprio código, não é typo deste dossiê). |
presence.subscribe | POST /user/presence/subscribe | Documentado no API.md (linhas 1010-1023) e no código (handlers.go:3446+). Corpo: {Phone}. A resposta HTTP síncrona só confirma o registro da inscrição — o resultado real (status online/offline, last_seen) chega depois, assíncrono, via webhook ("Presence" events) que este adapter não reconhece hoje (parseWebhookUnsafe não tem case 'Presence' — cairia em unknown). Depende também de a sessão estar com presença "available" (efeito colateral de presence.set) e das configurações de privacidade do contato (last_seen pode não vir). |
Etiquetas — NÃO implementado (ADR-0016)
Busca negativa confirmada, 0/6: nenhuma capability de labels.* é declarada nem implementada por este adapter. Busca dedicada no API.md e no código-fonte (handlers.go) não encontrou nenhuma rota, campo ou struct relacionada a etiquetas/labels/tags do WhatsApp Business — limitação real do provider (Wuzapi não expõe essa feature), não gap de pesquisa. Mesmo padrão de "busca negativa explícita, documentada em vez de omitida" já usado para presence.* do Z-API (ADR-0015) e contacts.getAbout da uazapi.
Canais (channels.list, ADR-0017)
Cobertura 1/6 — só list, SOMENTE LEITURA.
| Capability | Endpoint | Observações |
|---|---|---|
channels.list | GET /newsletter/list | Confiança Média — código confirmado (handlers.go:5248-5287; routes.go:169), não documentado no API.md. Sem parâmetros. Implementação: GetSubscribedNewsletters(ctx) monta {Newsletter: [<types.NewsletterMetadata>, ...]} e passa por Respond() — o helper comum a todo o adapter, que sempre envelopa em {code, success, data: {...}} (confirmado lendo Respond(): o payload é serializado, reparseado e colocado em data, mesmo padrão de todo outro endpoint). Array pode vir vazio [], nunca nulo (gc.Newsletter = []types.NewsletterMetadata{} explícito). ChannelInfo é mapeado do mesmo shape rico types.NewsletterMetadata já confirmado no Evolution GO (thread_metadata.{name,description}.text, subscribers_count como string) — o Wuzapi também é construído sobre whatsmeow. |
create/getInfo/delete/follow/unfollow NÃO implementados, busca negativa confirmada — busca exaustiva por "newsletter" em routes.go só encontrou essa uma rota (GET /newsletter/list). Só newsletters/canais que a própria conta já está inscrita aparecem na listagem — não há como descobrir/entrar em canais novos com o que existe hoje neste provider.
Perfil comercial (business.*, ADR-0018) — NÃO implementado
Busca negativa confirmada ao vivo em handlers.go (fonte já cacheada nesta sessão): nenhuma menção a "business" no arquivo inteiro — nenhum endpoint para ler/editar o perfil comercial da instância. Limitação real de plataforma (mesma raiz whatsmeow do Evolution GO, ver dossiê desse provider), não gap de pesquisa. WaAdapter.business não implementado.
Chamadas de voz (calls.reject, ADR-0019)
Cobertura 1/2 — só reject, confiança Alta no endpoint / Média na usabilidade.
| Operação canônica | Endpoint | Observações |
|---|---|---|
calls.reject | POST /call/reject | Código confirmado em handlers.go:6915-6971, não documentado no API.md. Body em snake_case (diferente do resto da API): {call_from, call_id}, AMBOS obrigatórios. Resposta {Details, CallID} (dentro do envelope Respond()) — ignorada, contrato exige Promise<void>. |
Nuance: para chamar este endpoint o consumidor precisa saber o call_id/call_from de uma chamada recebida — isso normalmente viria de um evento de webhook de tipo "Call"/CallOffer, que este adapter não reconhece hoje (parseWebhookUnsafe não tem nenhum case para eventos de chamada — cairiam em unknown). Este adapter lança INVALID_INPUT se callerId/callId faltarem. Sem calls.make: busca exaustiva por "call" em routes.go só encontrou essa uma rota — nenhum endpoint para originar chamada.
Conversas (chats.*)
| Operação canônica | Endpoint | Observações |
|---|---|---|
chats.archive | POST /chat/archive (archive: true) | Ver subseção dedicada abaixo. |
chats.unarchive | POST /chat/archive (archive: false) | MESMO endpoint de chats.archive. |
chats.mute/chats.unmute/chats.pin/chats.unpin/chats.markRead/chats.markUnread não foram declaradas — ver justificativas nas subseções abaixo.
chats.archive/chats.unarchive — POST /chat/archive, endpoint único com parâmetro booleano
Confirmado no código-fonte (handlers.go, func ArchiveChat; registrado em routes.go). Corpo: {jid, archive} — atenção de nomenclatura: tags JSON minúsculas (jid/archive), diferente da maioria dos outros endpoints deste provider (PascalCase sem tag) — mesma exceção já documentada para groups.create (name/participants). archive é um booleano: o mesmo endpoint cobre as duas direções (true = arquivar, false = desarquivar) — mesmo padrão de endpoint único reaproveitado por parâmetro já usado neste adapter em getInviteLink/revokeInviteLink (reset) e em addParticipants/removeParticipants/promoteParticipants/demoteParticipants (Action).
Internamente o handler chama client.SendAppState(ctx, appstate.BuildArchive(chatJID, t.Archive, time.Time{}, nil)) — é uma mutação real de app-state sincronizada com o WhatsApp (não um estado só local do servidor Wuzapi).
⚠️ Conversão de
chatIdnecessária — divergência confirmada em como ojidé parseado neste endpoint especificamente: diferente de TODO outro endpoint deste adapter (que, do lado do servidor, passam pelo helper interno lenientparseJIDdo Wuzapi — aceita dígitos crus, completando com@s.whatsapp.netquando a string não contém@), o handlerArchiveChatchamatypes.ParseJID(função crua da libwhatsmeow) diretamente sobre o campojid, sem passar pelo wrapper lenient.types.ParseJIDexige um@na própria string — sem ele, o servidor responde 400 ("invalid Chat JID format") antes mesmo de tentar a operação. Ou seja, umchatIdem dígitos crus (sem@), que funciona normalmente emmessages.sendText/sendReaction/chat/delete/chat/send/editneste mesmo provider, falharia emchats.archive/chats.unarchivese repassado como está. Este adapter completa o sufixo@s.whatsapp.netquando ochatIdrecebido não contém@(toWuzapiChatJid), para que as duas operações aceitem o mesmo formato de entrada (dígitos ou JID explícito) que todo o resto do adapter — sem essa conversão, um consumidor que só usa números de telefone (o caso mais comum) teriachats.archive/chats.unarchivequebrados de forma não óbvia.
Resposta confirmada: {"success": true, "message": "Chat archived"|"Chat unarchived"} (dentro do envelope padrão data) — inteiramente ignorada, contrato retorna Promise<void> para as duas operações.
chats.mute/chats.unmute/chats.pin/chats.unpin — não implementadas, sem endpoint
Busca exaustiva em routes.go (todas as rotas registradas via s.router.Handle(...), branch main, 2026-07-12) não encontra nenhum handler equivalente a mutar ou fixar uma conversa — confirma o achado já registrado em ADR-0012 para este subconjunto específico. Se uma versão futura do servidor adicionar esses endpoints, esta seção precisa ser revisitada.
chats.markRead/chats.markUnread — não implementadas, apesar de POST /chat/markread existir
Diferente do subconjunto acima, existe um endpoint POST /chat/markread (handlers.go, func MarkRead; registrado em routes.go) — achado que a pesquisa resumida em ADR-0012 não previu especificamente para este provider (a ADR generaliza "Wuzapi não tem nenhuma [operação de chats.*] exceto archive", o que se mostra impreciso para este caso específico: o endpoint existe, só não serve ao contrato canônico — ver abaixo).
O corpo aceito é {Id: string[], ChatPhone, SenderPhone, Chat?, Sender?} — Id é obrigatório e é uma lista de ids de MENSAGEM (len(t.Id) < 1 é rejeitado com 400 "missing Id in Payload"), não apenas um chatId. Ou seja, este endpoint implementa a operação de nível de mensagem ("marcar estas mensagens específicas como lidas") que a própria ADR-0012 reserva explicitamente para um eventual messages.markRead futuro — distinto de chats.markRead(chatId) (nível de chat: "marcar a conversa INTEIRA como lida", só com o chatId, sem precisar saber quais mensagens estão não lidas).
Não implementado por este motivo: não há como cumprir o contrato canônico chats.markRead (chatId): Promise<void> com este endpoint sem inventar ids de mensagem ou compor uma chamada extra (ex.: GET /chat/history para descobrir mensagens não lidas antes de marcá-las) — o que violaria a regra de uma única chamada HTTP por operação canônica (mesmo critério já usado em ADR-0010 para contacts.get/getAbout). chats.markUnread não tem NENHUM endpoint equivalente no código-fonte (nem com essa limitação) — ausência total, mesma conclusão da ADR-0012.
Operações core
| Operação canônica | Endpoint | Observações |
|---|---|---|
instance.connect | POST /session/connect + GET /session/qr (best-effort) | Body {Subscribe?: string[], Immediate: boolean}. Subscribe vem de WuzapiOptions.subscribe (se informado); Immediate usa WuzapiOptions.immediate (padrão true, para não bloquear a chamada por até 10s). O QR retornado é lido de data.QRCode (fallback data.qrcode, defensivo) da segunda chamada — que pode falhar de forma esperada (sessão ainda não connected, ou já loggedIn); a falha é engolida, ConnectResult.qr fica undefined. |
instance.status | GET /session/status | Ver tabela acima. |
instance.logout | POST /session/logout | Hard logout — exige novo QR/pairing na próxima conexão. POST /session/disconnect (soft) não é usado (semântica diferente, fora do escopo de logout). |
messages.sendText | POST /chat/send/text | Body {Phone, Body, Id?, LinkPreview?, ContextInfo?}. Phone recebe o chatId canônico sem transformação (ver seção de mapeamento abaixo). Id/LinkPreview não são expostos por SendTextInput — não enviados (servidor usa os próprios defaults: gera um ID, não busca preview). SendTextInput.mentions não tem campo confirmado em /chat/send/text na pesquisa original — segue silenciosamente ignorado (mesmo padrão já adotado no adapter Z-API para o mesmo caso). |
messages.sendMedia | POST /chat/send/image | /audio | /video | /document | /sticker | Um endpoint por MediaKind, mesma forma de corpo, trocando o nome do campo de mídia (Image/Audio/Video/Document/Sticker) — document exige também FileName. Ver seção dedicada abaixo. |
messages.sendReaction | POST /chat/react | Body {Phone, Body, Id}. Ver seção dedicada abaixo. |
ContextInfo (reply) — suposição documentada
ContextInfo exige StanzaID e Participant juntos (confirmado no dossiê de pesquisa). SendTextInput/SendMediaInput só carregam quotedId (equivalente a StanzaID) — não há um campo de "remetente da mensagem citada" no contrato canônico. Suposição deste adapter: quando quotedId é informado, Participant recebe o mesmo valor de Phone (o destinatário do envio). Isso é correto para o caso comum de responder, num chat 1:1, a uma mensagem que a própria contraparte enviou (nesse caso Participant == JID da contraparte == Phone). Para grupos, onde o remetente da mensagem citada pode ser um participante diferente do "chat" em si, essa suposição pode produzir um Participant incorreto (resposta enviada, mas potencialmente sem o contexto de citação renderizado corretamente pelo cliente do destinatário) — não validado contra uma instância real. Mesma limitação, em espírito, à assumida no dossiê do Evolution GO para quoted.participant.
messages.sendMedia — corpo e limitações
Body: {Phone, <Campo>: <data URI ou URL http(s)>, Caption?, Id?, MimeType?, ContextInfo?}.
- Só aceita data URI (
data:image/png;base64,...) OU URLhttp(s)— base64 cru sem o prefixodata:é rejeitado pelo servidor com erro explícito ("Image data should start with \"data:image/png;base64,\""). O adapter monta a data URI automaticamente quandomedia.base64é informado sem esse prefixo, usandomedia.mimeType(ou um mimetype-padrão porMediaKind, best-effort) — mesmo padrão já usado no adapter Z-API (resolveMediaValue).media.url, quando presente, é preferido e repassado como está. Captioné enviado para qualquerMediaKindquandoinput.captionestá presente — a pesquisa original descreve os endpoints irmãos como "mesma forma", sem restringirCaptionpor tipo (diferente do Z-API, que documenta explicitamente a ausência decaptionpara áudio/figurinha).FileNameé obrigatório quandokind === 'document'— o adapter lançaWaConnectorError('INVALID_INPUT', ...)semedia.filenameestiver ausente nesse caso.MimeTypeé opcional; quando ausente, o servidor detecta viahttp.DetectContentType.- Proteção SSRF client-side: o servidor rejeita IPs privados/loopback ao buscar mídia de URLs remotas (
main.go, transport customizado) — não é responsabilidade deste adapter, apenas registrado aqui para contexto.
messages.sendReaction — corpo, remoção e limitações
Confirmado em três lugares independentes: API.md (POST /chat/react), routes.go (registro da rota) e handlers.go (func React, que monta um waE2E.Message{ReactionMessage:{...}} e chama SendMessage do whatsmeow — é envio real, não só parsing de webhook).
Body: {Phone, Body, Id, Participant?}. Phone = destinatário (mesmo mapeamento de sendText/sendMedia, sem transformação). Body = emoji da reação (obrigatório — o servidor rejeita string vazia com 400 "missing Body in Payload"). Id = id da mensagem-alvo.
- Remover uma reação: a convenção canônica do waconector usa
emoji === ''(verSendReactionInput/ADR-0008), mas o Wuzapi não aceitaBodyvazio para isso — em vez disso, usa o literal especialBody: "remove"(handlers.go:if reaction == "remove" { reaction = "" }, que então monta oReactionMessagecom texto vazio internamente — a convenção real do protocolo WhatsApp/whatsmeow para desfazer uma reação). Este adapter traduzemoji === ''paraBody: "remove"na fronteira, para que o consumidor do waconector não precise conhecer esse detalhe do provider. - Reagir à própria mensagem enviada: o Wuzapi espera o prefixo
"me:"emIdnesse caso (ID[, Participant]— o prefixo é removido antes de usar, e ativaFromMe: truena chave da mensagem).SendReactionInputnão carrega essa distinção (não há campo "esta mensagem é minha?") — não implementado:Idé sempre enviado sem prefixo. Reagir à própria mensagem pode portanto não funcionar corretamente via este adapter; reagir a mensagens recebidas (o caso comum) funciona. Participantsó é usado pelo servidor quandoFromMeé falso (reação num grupo, a uma mensagem de um participante diferente do "chat").SendReactionInputnão tem um campo equivalente ao "remetente da mensagem-alvo" — não enviado, mesma limitação, em espírito, já documentada paraContextInfo.ParticipantemsendText/sendMedia.- Resposta em sucesso (dentro de
data):{"Details":"Sent","Timestamp":<unix seconds>,"Id":"<id da mensagem reagida>"}— atenção: aquiIdé o id da mensagem-ALVO (a que recebeu a reação), não um novo id de "mensagem de reação" (confirmado em prosa no próprioAPI.md). Mapeado com a mesmamapSentMessagedesendText/sendMedia—SentMessage.idreflete esse eco doIdrequisitado, não um novo identificador.
Formato de resposta de envio
Confirmado no dossiê: resposta de POST /chat/send/text (dentro de data): {"Details":"Sent","Timestamp":<unix seconds>,"Id":"<msgid>"}. Mapeado para SentMessage: id = data.Id (fallback wuzapi-<Date.now()>), chatId = Phone requisitado (a resposta não ecoa o destinatário), timestamp = data.Timestamp * 1000 (o dossiê confirma que o valor vem em segundos, não milissegundos — diferente da maioria dos outros exemplos deste pacote). O mesmo shape é assumido para POST /chat/send/* (mídia), por analogia — não confirmado individualmente por tipo de mídia na pesquisa original.
Mapeamento de chatId canônico → Wuzapi
O conector já normaliza o to recebido do usuário (normalizeChatId) antes de chamar o adapter: telefone vira só-dígitos (E.164 sem +), JIDs explícitos passam intactos. O campo Phone do Wuzapi é documentado como polimórfico (wmiau.go, func parseJID): se a string não contém @, vira user@s.whatsapp.net; se contém @, é parseada como JID literal (inclui grupos @g.us) — exatamente os dois formatos que o chatId canônico já produz. O adapter repassa sem transformação (toWuzapiPhone, função identidade — existe só como ponto único de mudança, mesmo padrão dos demais adapters deste pacote).
Grupos (núcleo)
14 operações implementadas (ADR-0009): groups.create, groups.getInfo, groups.list, groups.addParticipants, groups.removeParticipants, groups.promoteParticipants, groups.demoteParticipants, groups.updateSubject, groups.updateDescription, groups.updatePicture, groups.getInviteLink, groups.revokeInviteLink, groups.joinViaInviteLink, groups.leaveGroup.
| Operação canônica | Endpoint | Observações |
|---|---|---|
groups.create | POST /group/create | Body {name, participants} — atenção: tags JSON minúsculas, diferente da maioria dos outros endpoints deste provider (PascalCase sem tag); confirmado no código-fonte, não é engano. participants recebe input.participants (já normalizado pelo conector) passado por toWuzapiPhone (identidade). Resposta (data): {JID, Name, OwnerJID, GroupCreated, Participants: [{IsAdmin, IsSuperAdmin, JID}]}. |
groups.getInfo | GET /group/info | groupJID vai na query string (?groupJID=...), não no corpo. Resposta (data) = types.GroupInfo: {JID, OwnerJID, Name, Topic (=descrição), IsLocked, GroupCreated, Participants: [{JID, IsAdmin, IsSuperAdmin}]}. |
groups.list | GET /group/list | Sem parâmetros. Resposta (data): {Groups: [<mesmo shape de getGroupInfo>, ...]}. |
groups.addParticipants / removeParticipants / promoteParticipants / demoteParticipants | POST /group/updateparticipants (tudo minúsculo) | As 4 operações são o mesmo endpoint, variando só Action ("add"|"remove"|"promote"|"demote"). Body {GroupJID, Phone: string[], Action} — atenção: o campo se chama Phone, não Participants. Resposta: {Details: "Group Participants updated successfully"}, sem detalhe por participante (por isso o contrato retorna Promise<void> para as 4). |
groups.updateSubject | POST /group/name | Body {GroupJID, Name}. Name recebe input.subject (já validado não-vazio pelo conector). Resposta: {Details: "Group Name set successfully"} — Promise<void>. |
groups.updateDescription | POST /group/topic | Body {GroupJID, Topic}. Topic vazio é permitido — limpa a descrição do grupo (internamente o provider passa previousID/newID vazios ao whatsmeow); não é tratado como erro. Resposta: {Details: "Group Topic set successfully"} — Promise<void>. |
groups.updatePicture | POST /group/photo | Body {GroupJID, Image}. Ver subseção dedicada abaixo — só aceita JPEG de fato. Resposta: {Details: "Group Photo set successfully", PictureID} — PictureID é ignorado, Promise<void>. |
groups.getInviteLink / groups.revokeInviteLink | GET /group/invitelink | Mesmo endpoint, variando só o parâmetro reset (?groupJID=...&reset=false|true) — mesmo padrão de addParticipants/removeParticipants/etc. reaproveitando um único endpoint. groupJID/reset vão na query string, não no corpo (o exemplo de curl da doc oficial mostra --data, o que é enganoso — mesma divergência já confirmada em getInfo). reset=false busca o link atual sem invalidar; reset=true invalida o atual e gera um novo. Resposta: {InviteLink: "https://chat.whatsapp.com/<código>"}. Ver subseção dedicada abaixo. |
groups.joinViaInviteLink | POST /group/join | Body {Code: string} — CONFIANÇA MÉDIA, não confirmado empiricamente. Ver subseção dedicada abaixo. Resposta: {Details: "Group joined successfully"} — ignorada, Promise<void>. |
groups.leaveGroup | POST /group/leave | Body {GroupJID: string}. Resposta: {Details: "Group left successfully"} — ignorada, Promise<void>. |
groups.getInviteLink/groups.revokeInviteLink — link sempre completo
A resposta confirmada de GET /group/invitelink (data.InviteLink) já vem no formato completo (https://chat.whatsapp.com/<código>) na pesquisa original, mas o adapter passa o valor por normalizeInviteLink (core/chat-id.ts) antes de retornar de qualquer forma — operação idempotente quando o valor já é o link completo, e defensiva caso alguma versão do servidor devolva só o código bare. GroupInviteLink.link do waconector é sempre o link completo, nunca o código bare (ver core/types.ts).
groups.joinViaInviteLink — assunção não validada: só o código, não o link completo
O conector já normaliza input.invite para o link completo (https://chat.whatsapp.com/<código>) antes de chamar qualquer adapter (ver WaConnector.prepareJoinViaInviteLink em core/connector.ts) — não importa o que o consumidor final passou (código bare ou link completo), o adapter sempre recebe o link completo. A pesquisa original para este provider não confirmou empiricamente se POST /group/join aceita a URL completa do convite ou exige só o código bare — este adapter assume/trata como aceitando só o código, extraindo-o do link completo recebido via extractInviteCode (core/chat-id.ts) antes de montar {Code: <código>}. Se uma instância real confirmar que o endpoint aceita o link completo também (ou que rejeita o formato assumido aqui), este comportamento precisa ser revisto — não validado contra uma instância Wuzapi real.
groups.leaveGroup
Sem particularidades além do já documentado: groupId é opaco, repassado intacto em GroupJID (mesmo tratamento de getInfo/updateSubject/etc.).
groups.updatePicture — só aceita JPEG de fato (requisito rígido confirmado no código-fonte)
O handler de POST /group/photo confere tanto o prefixo "data:image/" quanto os magic bytes reais (0xFF 0xD8 0xFF, assinatura JPEG) e rejeita com 400 qualquer outro formato — inclusive se o prefixo textual disser data:image/png ou data:image/webp. Uma mensagem de erro do servidor (não relacionada a este check específico) sugere que png/gif/webp também seriam aceitos — isso é enganoso; confirmado lendo o código-fonte, não a prosa da mensagem de erro.
Conversão de MediaRef feita pelo adapter (toWuzapiGroupPhoto):
media.base64presente: monta a data URI forçandoimage/jpeg(data:image/jpeg;base64,<...>), ignorandomedia.mimeTypecaso informado — o adapter assume/força que o payload já está codificado como JPEG, já que é a única coisa que o servidor aceita de fato. Semedia.base64já chegar como uma data URI completa (com qualquer mimetype no prefixo, ex.data:image/png;base64,...), o adapter extrai só a porção após a vírgula antes de remontar com o prefixo forçado — evita produzir uma string com dois cabeçalhos concatenados. Responsabilidade do chamador: garantir que os bytes reais sejam JPEG; este pacote não reencoda imagens client-side (ADR-0004, zero dependências de runtime).- Só
media.urlpresente (semmedia.base64): repassada como está, sem nenhuma conversão — não há como baixar/reencodar a imagem localmente. Assunção não validada contra instância real: a pesquisa original confirma quePOST /group/photoaceita uma data URI JPEG, mas não há confirmação de que este endpoint específico aceite uma URLhttp(s)crua (diferente demessages.sendMedia, cujo suporte a URL é confirmado). Se o servidor rejeitar URLs cruas neste endpoint, o consumidor precisa fornecermedia.base64em vez demedia.urlparagroups.updatePicture.
⚠️ Divergência confirmada: doc vs. código em GET /group/info
O API.md oficial mostra um exemplo de curl enganoso para este endpoint, com --data '{"GroupJID":"..."}' sugerindo um body JSON. O handler do servidor só lê a query string (?groupJID=...) e ignora qualquer corpo enviado — confirmado lendo o código-fonte, não apenas a prosa da doc. Este adapter envia groupJID exclusivamente via query, sem body.
groupId — identificador opaco (ver ADR-0009)
Diferente da Z-API (que usa um ID sintético sem @), o Wuzapi usa JID padrão (<dígitos>@g.us para grupos, <dígitos>@s.whatsapp.net para indivíduos) tanto para grupos quanto para participantes — o mesmo formato aceito por parseJID em qualquer campo de destinatário. Ainda assim, groupId não passa por normalizeChatId/toWuzapiPhone no caminho de getInfo/addParticipants/etc.: é repassado tal como chega ao adapter (o conector já trata groupId como opaco para todo provider, uniformemente — ver WaConnector.requireGroupId), o que funciona sem problema aqui porque o valor típico (obtido de um GroupInfo.id anterior) já vem no formato @g.us esperado pelo servidor.
Participantes individuais em groups.*
Diferente de groupId, os itens de participants/Phone são tratados como um to de mensagem comum: o conector já os normaliza (telefone -> só-dígitos, JID passa intacto) antes de chamar o adapter, e este reaproveita toWuzapiPhone (identidade) — mesmo helper usado por sendText/sendMedia/sendReaction.
Fallback quando a resposta não ecoa todos os campos (groups.create)
POST /group/create normalmente ecoa Name/Participants na resposta, mas quando não ecoa (variação de versão do servidor), o adapter cai de volta em input.subject/nos participantes requisitados (com isAdmin/isSuperAdmin assumidos como false) — mesmo padrão de fallback já usado em mapSentMessage (chatId ?? requestedNumber).
Contatos
8 operações implementadas (ADR-0010): contacts.list, contacts.get, contacts.checkExists, contacts.getProfilePicture, contacts.getAbout, contacts.block, contacts.unblock, contacts.listBlocked. Regra de ouro seguida à risca: cada operação canônica dispara uma única chamada ao provider — o melhor match disponível. Quando o endpoint mais próximo não traz um campo (nome de exibição em contacts.get, ou a URL real da foto), esse campo fica undefined no Contact/ContactProfilePicture retornado — não há composição de chamadas para completá-lo.
| Operação canônica | Endpoint | Observações |
|---|---|---|
contacts.list | GET /user/contacts | Sem corpo. Resposta (data) é um mapa JID(string) -> {Found, FirstName, FullName, PushName, BusinessName, RedactedPhone?} (chaves PascalCase, sem tags JSON no struct Go). Ver subseção dedicada abaixo. |
contacts.get | POST /user/info | Body {Phone: [chatId]}. Não existe um endpoint único ideal para "getContact" — este é o melhor match (uma única chamada, ADR-0010). Ver subseção dedicada abaixo — sem nome de exibição. |
contacts.checkExists | POST /user/check | Body {Phone: [phone]}. Resposta (data.Users) é um array de {Query, IsInWhatsapp, JID, VerifiedName} (struct diferente de contacts.get, sem tags JSON). Ver subseção dedicada abaixo. |
contacts.getProfilePicture | POST /user/avatar | Body {Phone: chatId, Preview: false}. Ver subseção dedicada abaixo — divergência confirmada entre API.md e o código-fonte (envelope e método HTTP). |
contacts.getAbout | POST /user/info | MESMO endpoint de contacts.get — reaproveita a mesma função interna (fetchUserInfoEntry), extraindo só o campo Status. |
contacts.block | POST /user/block | Body {Phone: chatId} (o servidor também aceita {JID}, mas este adapter usa Phone por consistência com o resto do adapter). Resposta (data): {Details: "User blocked", JID, Blocklist, DHash, RequestedJID?} — ignorada, Promise<void>. |
contacts.unblock | POST /user/unblock | Mesmo shape de contacts.block (body {Phone: chatId}). Resposta: {Details: "User unblocked", ...} — ignorada, Promise<void>. |
contacts.listBlocked | GET /user/blocklist | Sem corpo. Resposta (data): {Blocklist: string[] (nunca null — lista vazia quando ninguém está bloqueado), DHash}. Mapeada diretamente para string[] (chatIds no formato canônico, mesmo formato de Contact.id). |
contacts.list — mapa JID -> {Found, FirstName, FullName, PushName, BusinessName, RedactedPhone?}
A chave do mapa vira Contact.id; name usa FullName (fallback FirstName, fallback PushName) — nenhum dos três é garantido presente para todo contato. Igual a groups.list (mapGroupInfo), raw é o objeto DAQUELE contato específico (não o mapa inteiro), para que cada Contact preserve só o payload que o descreve.
Sem about/profilePictureUrl/hasWhatsApp/isBlocked neste endpoint — nenhum campo equivalente na resposta de GET /user/contacts; ficam undefined (usar contacts.getAbout / contacts.getProfilePicture separadamente quando necessário).
contacts.get — POST /user/info, sem nome de exibição, PictureID não é a URL
Body {Phone: [chatId]} — o array tem só um elemento por chamada. Resposta (data): {Users: {<JID>: {VerifiedName, Status, PictureID, Devices, LID}, ...}} — também um mapa (mesmo formato de contacts.list), mas como só uma Phone é enviada, só uma entrada é esperada; a primeira (e única) é usada, na ordem de inserção do objeto (fetchUserInfoEntry/ firstRecordValue).
Mapeamento: Status -> Contact.about.
nameficaundefined— a resposta não traz nome de exibição algum. Mesma limitação já documentada para o adapter Evolution GO (mesma libwhatsmeowsubjacente): nenhum campo do tipoPushName/FullNameaparece em/user/info.profilePictureUrlficaundefined—PictureIDé só um identificador/hash interno da foto atual do contato, não é a URL. PopularprofilePictureUrla partir dele exigiria uma segunda chamada (POST /user/avatar, usada porcontacts.getProfilePicture), que este adapter não compõe (regra de ouro do ADR-0010, ver topo desta seção) — quem precisar da URL da foto deve chamarcontacts.getProfilePictureseparadamente.idé o própriochatIdrequisitado (já canônico, mesmo padrão de fallback demapSentMessage), não a chave do mapaUsers— evita depender do formato exato do JID devolvido pelo servidor.hasWhatsApp/isBlockedtambém ficamundefined— sem campo equivalente confirmado na pesquisa para este endpoint.
contacts.checkExists — POST /user/check, JID não confirma existência
Body {Phone: [phone]}. Resposta (data.Users) é um array — struct diferente do mapa usado por contacts.get/contacts.list, sem tags JSON ({Query, IsInWhatsapp, JID, VerifiedName}). Só uma Phone é enviada por chamada, então o primeiro item do array é usado.
Mapeamento: IsInWhatsapp -> exists, JID -> chatId.
⚠️
JIDvem preenchido MESMO QUANDOIsInWhatsappéfalse— é o JID sintetizado a partir do número consultado (<dígitos>@s.whatsapp.net), não uma confirmação de que o número existe no WhatsApp. Este adapter ainda assim mapeiaJID->CheckExistsResult.chatIdsempre que presente (o contrato central já documenta que "nem todo provider devolve isso quandoexistséfalse" — aqui o Wuzapi devolve um valor, só que sem significado de confirmação). Consumidores que dependem dechatIdcomo sinal de existência confirmada devem checarexistsprimeiro.
Resposta vazia (Users: [], ex. array vazio quando o telefone é inválido) mapeia para exists: false, chatId: undefined, sem lançar.
contacts.getProfilePicture — POST /user/avatar, divergência doc-vs-código confirmada
Body {Phone: chatId, Preview: false}.
⚠️ Divergência de formato de resposta confirmada entre a doc e o código-fonte: o
API.mddocumenta um exemplo de resposta diferente (objeto bare em PascalCase, sem o envelope{code,success,data}usado por todo o resto da API). O código-fonte real (handlers.go) usa tags JSON minúsculas e sempre embrulha a resposta no envelope padrão — este adapter confia no código-fonte, não na prosa da doc (mesmo critério já usado nas demais divergências deste dossiê):data = {url, id, type, direct_path, hash}, mapeado comodata.url->url.⚠️ Divergência de método HTTP confirmada:
API.mddocumenta esta rota comoGET, masroutes.goregistra o handler emPOST— este adapter usaPOST, confiando no registro de rotas do código-fonte.
url fica undefined quando a resposta não inclui o campo (contato sem foto, ou privacidade não permite) — nunca lança.
contacts.getAbout — mesmo endpoint de contacts.get
Reaproveita a mesma função interna (fetchUserInfoEntry, POST /user/info) usada por contacts.get — mesma extração do campo Status, mesma limitação de "só uma entrada esperada no mapa Users". Cada operação ainda dispara sua própria chamada HTTP (uma por operação, nunca composta) — reaproveitar a função não significa reaproveitar a resposta entre get/getAbout.
contacts.block / contacts.unblock / contacts.listBlocked — moderação de contato
Diferente das 5 operações de descoberta/perfil acima (retrofit anterior, ADR-0010 PR1), estas 3 operações de moderação foram confirmadas num PR posterior — e, ao contrário do WAHA e da Z-API (que não têm cobertura confirmada de listBlocked na pesquisa desses dois providers), o Wuzapi confirma as 3 operações, então as 3 são declaradas por este adapter.
contacts.block(POST /user/block) econtacts.unblock(POST /user/unblock) usam o MESMO shape de corpo:{Phone: chatId}(viatoWuzapiPhone, o mesmo helper de identidade reaproveitado pormessages.*/contacts.get/etc.). O servidor também aceita{JID: chatId}como alternativa — este adapter usaPhonepor consistência com o resto do adapter, não por necessidade do provider. Resposta (data):{Details: "User blocked"|"User unblocked", JID, Blocklist: string[], DHash, RequestedJID?}— inteiramente ignorada, ambos retornamPromise<void>(mesmo padrão deleaveGroupCall/updateGroupSubject, que também descartam o corpo da resposta). O provider resolve internamente@lid-> telefone antes de bloquear quando aplicável — transparente para este adapter, nenhum tratamento especial necessário.contacts.listBlocked(GET /user/blocklist, sem corpo) mapeiadata.Blocklistdiretamente para ostring[]do contrato — a resposta confirma que o campo nunca vemnull(lista vazia quando ninguém está bloqueado), então nenhum fallback defensivo adicional é necessário além do já existente emasStringArray(que também descarta itens não-string, por segurança). Os chatIds retornados já vêm no formato canônico (mesmo formato usado emContact.id).
Webhooks
Configuração por usuário: POST /webhook (header token), body {"webhookurl":"https://...","events":["Message","ReadReceipt",...]}.
⚠️ Divergência confirmada: a tag JSON real no struct Go é
webhookurl(tudo minúsculo, uma palavra só) — os próprios exemplos doAPI.mdusamwebhookURL; funciona mesmo assim porque oencoding/jsondo Go faz match case-insensitive de fallback quando a tag exata não bate. Não afeta o parsing deste adapter (que só recebe webhooks, não os envia/configura), citado aqui só para quem for consumir o pacote e configurar oPOST /webhookmanualmente.
GET /webhook retorna {"webhook":"...","subscribe":[...]}. DELETE /webhook limpa webhook+events. Existe ainda PUT /webhook (handler UpdateWebhook, não mencionado na seção principal do API.md) com body diferente: {"webhook":"...","events":[...],"active":bool} — se active=false, zera webhook e events.
Formato de entrega (WEBHOOK_FORMAT, env var global do servidor — não por usuário)
form(default):POST application/x-www-form-urlencodedcom exatamente os camposjsonData(string contendo o JSON do evento),userID,instanceName.json:POST application/jsoncujo body é o objeto do evento comuserIDeinstanceNamemesclados no topo.
Como o formato é uma configuração global do servidor (não controlável por chamada), este adapter aceita ambos defensivamente: se o body tiver um campo jsonData (string), ele é parseado como JSON para obter o evento real; caso contrário, o body inteiro já é tratado como o objeto do evento (modo json). userID/instanceName são lidos do nível que estiver disponível em cada modo.
⚠️ Divergência confirmada: o próprio
API.md(seção "Webhook format configuration") afirma que ambos os formatos incluem um campotokencom o token do usuário no payload entregue — isso é falso no código-fonte atual (helpers.gofunccallHookWithHmac/wmiau.gofuncsendToUserWebHookWithHmacconstroem o payload só comjsonData/userID/instanceName, ou o merge deuserID+instanceNameno modojson;tokennunca é adicionado ao payload). O parser deste adapter não depende de um campotokenno payload de webhook — só usauserID/instanceNamepara popularinstanceId, então essa divergência não afeta o adapter.
Quando HMAC está configurado (hmac_key do usuário), o header x-hmac-signature (SHA-256) é enviado, assinando o body JSON cru (modo json) ou a string form-urlencoded (modo form). Não implementado neste adapter — WebhookInput.rawBody está disponível no contrato para isso, mas verificar a assinatura fica a cargo do consumidor (mesma decisão já tomada nos adapters uazapi/ Z-API, que também não implementam verificação de HMAC/assinatura).
⚠️ Payloads RECONSTRUÍDOS — não são exemplos literais da documentação
Nenhum dos três payloads abaixo foi copiado literalmente de uma captura real ou de um exemplo publicado no API.md. O wrapper externo {"type": "...", "event": {...}} é confirmado no código-fonte (wmiau.go, ~L841-1157, switch sobre eventos do whatsmeow) — o que é reconstruído é o conteúdo interno de event para Info/Message (serialização do struct whatsmeow/types/events.Message, cujo código-fonte da própria lib whatsmeow não foi consultado na pesquisa original). Tratar como plausível, não como fato confirmado, até validação empírica contra uma instância real.
fixtures/webhook-message-received.json—type: "Message",event: {Info: {...}, Message: {...}}.event.Info(ID,Chat,Sender,IsFromMe,IsGroup,PushName,Timestamp) e o conteúdo deevent.Message(conversation,extendedTextMessage,imageMessage, ...) foram montados por analogia direta com o adapter Evolution GO (mapMessageContentemsrc/adapters/evolution/index.ts), já que ambos os providers envolvem eventos da mesma libwhatsmeow— mas essa analogia em si não foi validada contra uma instância Wuzapi real. Quando o usuário temmedia_deliveryconfigurado (campo por usuário:base64default |s3|both), o payload de mídia ganhamimeType/base64/fileNameno nível raiz do evento (fora deevent) — não dentro deevent.Message; o adapter usa esses campos de raiz para completarWaMessage.media.base64quando presentes.event.Info.Timestampé assumido como string ISO/RFC3339 (serialização default detime.Timeem Go) — o adapter aceita também epoch numérico como fallback defensivo (mesma funçãotoEpochMsusada no Evolution GO).fixtures/webhook-ack.json—type: "ReadReceipt",state∈Delivered/Read/ReadSelf(estes três valores são confirmados no código-fonte, ~L1138-1157 dewmiau.go— outros tipos deReceiptsão descartados e não geram webhook).event.MessageIDs/event.Chatseguem o mesmo shape do adapter Evolution GO para o evento equivalente. Mapeamento:Delivered→delivered,Read/ReadSelf→read; qualquer outro valor cai emsent(fallback neutro, nunca lança).fixtures/webhook-connection-update.json—type: "Connected",event: {}(assumido como struct marcador vazio, padrão comum paraevents.Connectedna lib whatsmeow — não confirmado). Outros valores detypeconfirmados no código (wmiau.go, mesmo switch):Disconnected,LoggedOut,ConnectFailure(carregaerror/attempts/reasonnoevent),PairSuccess,QR,QRTimeout. Mapeamento aplicado:Connected→connected,Disconnected/LoggedOut/ConnectFailure→disconnected,PairSuccess→connected,QR→qr,QRTimeout→disconnected(suposição — o código confirma que o valor existe, mas não detalha a semântica exata; tratado como "parou de esperar o scan", análogo a uma desconexão do fluxo de pareamento). Qualquer outrotypenão reconhecido vira evento canônicounknown, nunca lança.⚠️
QRtem shape diferente de todos os outros tipos: confirmado emwmiau.go, funcstartClient(loopfor evt := range qrChanquandoevt.Event == "code"), o servidor montapostmap["event"] = evt.Event— uma string literal"code", não um objeto — epostmap["qrCodeBase64"] = base64qrcodecomo campo irmão detype/event, no nível raiz do payload (mesmopostmapserializado porsendEventWithWebHook, sem reestruturação). Ou seja, o payload de wire real é{"type":"QR","event":"code", "qrCodeBase64":"data:image/png;base64,...", ...}. O adapter lêqra partir do nível raiz do evento (eventRecord.qrCodeBase64), não de dentro deevent— mesmo padrão já usado porattachRootMediapara mídia recebida (base64/mimeType/fileNametambém no nível raiz, confirmado emmedia.go, funcprocessMedia). Verfixtures/webhook-qr.json.
Webhooks de grupo (GroupInfo/JoinedGroup) — RECONSTRUÍDO, confiança MÉDIA-ALTA
⚠️ Diferente dos webhooks de mensagem/conexão acima (que ao menos têm um dossiê de pesquisa dedicado por analogia com o Evolution GO), estes dois casos foram reconstruídos diretamente do código-fonte da lib
tulir/whatsmeow(viawmiau.go/constants.go) sem nenhum exemplo real capturado para o Wuzapi. Tratar como plausível — a estrutura externa ({type, event}, nomes de campo) tem confiança média-alta por vir do código-fonte da própria lib; o comportamento exato ainda não foi validado contra uma instância real. Sessão precisa se inscrever explicitamente em"GroupInfo"/"JoinedGroup"(ou usar o valor especial"All") no arraySubscribedePOST /session/connect— sem isso, esses eventos não chegam (ver seção "Modelo de instância/sessão" acima).
GroupInfo— serialização (assumida verbatim) do structwhatsmeow/types/events.GroupInfo:{JID, Notify, Sender, SenderPN, Timestamp, Name, Topic, Locked, Announce, Ephemeral, MembershipApprovalMode, Delete, Link, Unlink, NewInviteLink, PrevParticipantVersionID, ParticipantVersionID, JoinReason, Join, Leave, Promote, Demote, Suspended, Unsuspended, UnknownChanges}. Um único evento pode carregar múltiplas mudanças simultâneas (comum em providers baseados em whatsmeow) — o adapter (mapGroupInfoEvent) emite umGroupUpdateEventpor mudança identificada (natural porqueparseWebhookjá retornaCanonicalEvent[]):- Implementado (
actionpopulado):Join(array) não-vazio →action: 'participants.add',participants= os JIDs do array.Leave(array) não-vazio →action: 'participants.remove', idem.Promote(array) não-vazio →action: 'participants.promote', idem.Demote(array) não-vazio →action: 'participants.demote', idem.Namepopulado (objeto{Name, NameSetAt, NameSetBy, ...}presente) →action: 'subject', semparticipants— o "novo nome" em si não é extraído (não confirmado queName.Nameé sempre o valor correto/completo; quem precisar do valor atual deve chamargroups.getInfo).Topicpopulado (objeto{Topic, TopicID, ...}presente) →action: 'description', mesma lógica/limitação do item acima (sem extrair o texto da nova descrição).
- Não implementado (cai em
unknown, nunca lança, nunca inventa):Locked,Announce,Ephemeral,MembershipApprovalMode,Delete,Link/Unlink/NewInviteLink,Suspended/Unsuspended,UnknownChanges— nenhum desses tem shape ou exemplo confirmado na pesquisa original; mapeá-los exigiria inventar uma convenção deaction/valor sem base real (ADR-0002/ADR-0003). Se um eventoGroupInfosó contiver mudanças desta lista (nenhuma das reconhecidas acima), o evento inteiro cai emunknowncomreasonexplicando o motivo — não é uma regressão, é a postura correta diante de campos não confirmados. - Assunção de formato não confirmada: os itens de
Join/Leave/Promote/Demotesão tratados como strings de JID ("<dígitos>@<server>"), pela mesma convenção já usada eminfo.Chat/info.Sender(mapMessageEvent) e emParticipants[].JID(mapGroupParticipants) — mas não há confirmação específica de que a lib serializatypes.JIDcomo string (em vez de um objeto{User,Server,...}) exatamente nestes 4 campos. Itens que não forem string são silenciosamente descartados do array (viaasStringArray, nunca lança) — na prática, se a assunção estiver errada, o array resultante fica vazio e a mudança correspondente simplesmente não gera evento (comportamento seguro, não inventa dado). groupId(event.JID) é tratado como string com a mesma confiança doJIDusado em todas as outras respostas degroups.*deste adapter.
- Implementado (
JoinedGroup— disparado quando a própria sessão é adicionada a/entra num grupo. Serialização assumida:{Reason, Type, CreateKey, Sender, SenderPN, Notify}+types.GroupInfoembutido no mesmo nível (JID,Name,Participants, ...). Implementado:groupId=event.JID→GroupUpdateEvent { action: 'participants.add' }, semparticipants—event.Participantsaqui é a lista completa de membros do grupo (não quem entrou), então usá-la comoparticipantsinventaria uma semântica que o campo não tem.Reason/Type/CreateKey/Notifynão são mapeados (sem campo canônico equivalente e sem necessidade confirmada).- Fixtures (
src/adapters/wuzapi/fixtures/, todas marcadas como reconstruídas nesta seção):webhook-group-participants.json(Join+Leave+Promote+Demote simultâneos → 4 eventos),webhook-group-metadata.json(Name+Topic simultâneos → 2 eventos,subject+description),webhook-group-unknown-change.json(sóLocked→ 1 eventounknown),webhook-joined-group.json(JoinedGroup→ 1 eventoparticipants.add). - O que falta para validar: capturar payloads reais de uma instância Wuzapi com
Subscribeincluindo"GroupInfo"/"JoinedGroup"(ou"All") para confirmar (a) seJoin/Leave/Promote/Demotede fato serializam como array de string, (b) o shape exato deName/Topicquando populados, e (c) seLocked/Announce/etc. justificam mapeamento futuro.
Limites e particularidades
- Não há rate limiting embutido no servidor (busca por
RateLimit/Limiterno código-fonte inteiro não encontrou nada) — os únicos limites vêm do próprio WhatsApp/whatsmeow. - Toda resposta HTTP (sucesso ou erro) é envelopada:
{"code":<status>,"success":bool,"data":{...}}em sucesso ou{"code":<status>,"success":false,"error":"<msg>"}em erro (handlers.go, funcRespond) — o adapter sempre desembrulhadataantes de mapear para os tipos canônicos. - Destinatário em
/chat/send/*aceita diretamente JIDs de grupo (...@g.us), não só contatos individuais. - Subscrição de eventos é por usuário, persistida como string separada por vírgula na coluna
events(ex."Message,ReadReceipt"); o valor especial"All"subscreve a todos os ~45 tipos suportados (constants.go), incluindo eventos de grupo, chamada, presença, newsletter/canais, ponte Facebook/Meta etc. — nenhum desses tipos adicionais é mapeado por este adapter nesta fase (cai emunknown, nunca lança). - Integrações adicionais fora do escopo webhook simples: RabbitMQ global (
RABBITMQ_URL/RABBITMQ_QUEUE) publica todos os eventos independente de subscrição, e um webhook global (WUZAPI_GLOBAL_WEBHOOK) roda em paralelo ao webhook por usuário — nenhum dos dois é usado/necessário por este adapter. - Quando S3 está habilitado (
media_delivery: 's3'|'both'), o payload de webhook ganha uma chaves3comurl/key/bucket/size/mimeType/fileName(documentado com exemplo real no próprioAPI.md) — não consumido por este adapter nesta fase (WaMessage.mediasó usabase64/campos do protobuf quando presentes). GET /session/qrsó retorna sucesso se a sessão estiverconnectede ainda nãologgedIn— ver seção "Modelo de instância/sessão" acima.- O código-fonte da lib
tulir/whatsmeowem si não foi consultado na pesquisa original — os shapes deevent.Info/event.Message(mensagem) e do conteúdo por tipo de mídia são inferidos por analogia com o Evolution GO (mesma lib subjacente), não confirmados campo-a-campo para o Wuzapi especificamente. Ver aviso no topo do dossiê e na seção de webhooks. - Não foi possível confirmar se
POST /admin/userspermite omitir o campotokenpara autogeração — o código lido exige o token no body e só checa unicidade; sem garantia de que não exista um branch alternativo em versão mais recente (fora do escopo deste adapter de qualquer forma, já que/admin/usersnão é usado por nenhuma capability implementada). - O comportamento exato do campo
expiration(aceito na criação do usuário) não foi confirmado além do que o próprio README afirma ("not enforced by the system") — fora do escopo deste adapter.