WHMCS — conector de tickets + avisos por qualquer canal
PORT=3022 bun extensions/whmcs/src/server.ts # ou: bun run dev:whmcs
Duas metades, uma instalação. Elas são independentes de propósito: a ponte funciona sem token nenhum, e os avisos funcionam mesmo num WHMCS onde ninguém liga o espelho de tickets.
| Ponte de tickets | Avisos | |
|---|---|---|
| Caminho | módulo ⇄ esta extension, assinado (HMAC) | módulo → API REST do painel |
| Credencial | chave compartilhada, gerada aqui e gravada no download | token de API que o operador cria (opcional) |
| Sentido | os dois | só saída |
| Sem ela | tickets não espelham | avisos não saem; tickets continuam |
Como o conector conversa com o WHMCS
WHMCS (hook) ──push assinado──▶ POST <painel>/api/extensions/whmcs/<conn>/hook
│ ticket.created · ticket.message · ticket.updated
▼
WhmcsRuntime.ingest/ticket → ticket no painel
atendente responde ──▶ send() ──POST assinado──▶ <whmcs>/modules/addons/unifydesk/api.php
{action:"reply"} → AddTicketReply
A ação vai dentro do corpo assinado, nunca em ?action=: a assinatura cobre
os bytes, então uma ação na query string poderia ser trocada por outra num
payload que continua conferindo. O único ?action= é o ping, que não é
autenticado e não devolve segredo nenhum.
404 nunca é resposta do módulo. O conector lê 404 como "o módulo não está instalado" (o servidor web respondendo por um arquivo que não existe), então ticket inexistente responde 422. Reaproveitar o 404 poria "instale o módulo" na tela de uma instalação onde ele está rodando.
Arquivos
src/
server.ts SÓ boot: runExtension(extension)
extension.ts a DEFINIÇÃO (import-safe: manifest, rota /hook, blocks)
config.ts schema TypeBox da conexão (URL, chave, checar instalação)
runtime.ts a conexão: probe, assinatura, ingest, reply, query
php.ts monta o unifydesk.zip (grava lib/Install.php)
php/unifydesk/ o MÓDULO WHMCS — fonte canônica, é o que o botão entrega
frontend/ página da conexão (HTML + Alpine, sem texto cravado)
langs/ pt_BR / en_US (o painel resolve no idioma do OPERADOR)
O módulo PHP mora aqui e não numa pasta solta do repo porque ele é o outro lado
deste conector: quem muda o payload de um lado tem que mudar o do outro no mesmo
commit, e um php/ ao lado do src/ é o que torna isso óbvio.
O que o download já leva gravado
php.ts reescreve só lib/Install.php, com quatro constantes:
| Constante | O que é |
|---|---|
ENDPOINT |
a URL pública desta conexão (para onde o WHMCS empurra) |
SECRET |
a chave compartilhada desta conexão |
PANEL_URL |
o endereço do painel, para a metade REST não ser digitada |
CONNECTION_ID |
qual conexão serviu o download (diagnóstico) |
Um replace que não casa falha o build do pacote em vez de entregar um
arquivo sem valor: um módulo que instala limpo e nasce desconectado é a falha
mais cara que esta tela pode ter (__tests__/install.test.ts guarda isso).
O módulo, por dentro
unifydesk/
unifydesk.php addon (config/activate/upgrade) → Admin\Controller
hooks.php a TABELA de hooks, uma linha por evento
api.php o endpoint de máquina → Bridge\Server
lib/
Install.php as 4 constantes gravadas no download
Bridge/ Push (WHMCS→painel) · Server (painel→WHMCS) · Payload · Query
Api/ cliente REST do painel (Bearer, retry) — só os avisos
Notify/ Catalog · Events · Context · Channels · Recipient · Dispatcher · Queue · Sender
Hooks/ TicketBridge · Notifications · Cron
Admin/ Controller + as três abas
Support/ Settings · Schema · Logger · Lang · Renderer · Signature · Phone · Whmcs
lang/ english · portuguese-br (chrome do admin + corpos de mensagem)
Destinatário: do CLIENTE, canonizado pelo CONECTOR
Nada na config diz como falar com um cliente. A config tem duas escolhas: qual conexão do UnifyDesk é o WhatsApp e qual é o Discord. O endereço em si vem do próprio cliente:
| Canal | De onde sai | Como vira referência |
|---|---|---|
| telefone que ele deu no cadastro | resolve-target → [email protected] |
|
| Discord | conta que ELE vinculou por OAuth na área do cliente | resolve-target com dm:<userId> → <dmChannelId>:<userId> |
POST /api/tickets/resolve-target é a MESMA chamada que o painel faz quando um
atendente abre conversa na mão. Usá-la aqui não é reaproveitamento: o conector é
a única coisa que conhece o próprio formato de endereço, e a referência que ele
devolve é a que o inbound carrega. Mandar para uma grafia diferente abre um
SEGUNDO ticket quando o cliente responde.
Os três estados do contrato ficam separados:
exists: true→ válidoerror/exists: false→ inválido, o envio é pulado nomeando o motivo, e o aviso aparece na página do cliente no admin do WHMCS (AdminAreaClientSummaryPage) — é onde está a pessoa que pode consertarexistsausente → não verificado: o conector diz explicitamente que não deu para checar. Isso NÃO vira vermelho e NÃO barra o envio — pintar de vermelho um número perfeitamente alcançável ensina o atendente a ignorar o aviso que importa
Quem resolve é sempre o worker, nunca o hook: ClientAdd roda dentro do
cadastro do cliente e InvoicePaid dentro do callback do gateway. O hook só
responde o que o banco sabe (tem telefone? tem Discord vinculado?) — esse é o
skip que vale reportar na hora — e o resto espera o cron.
Discord: OAuth do CLIENTE, não campo do admin
O id do Discord é um snowflake que ninguém digita de memória. Admin preenchendo campo é admin adivinhando, e o preço de adivinhar errado é a fatura de alguém chegando no DM de um estranho. Então:
- Admin põe Client ID e Client Secret na config e copia a Callback URL que a tela mostra para o Discord Developer Portal → OAuth2 → Redirects, e liga User Install em Installation Contexts — sem ele o Discord recusa a URL de autorização.
- O cliente vê uma notificação fixada (
ClientAlert) no topo de toda página da área do cliente, com o link direto. Ele clica, autoriza (escopoidentify guilds.join applications.commands, comintegration_type=1), e a notificação some. Os dois últimos existem pelo DM, não por cargo nem por comando: o Discord barra DM de bot com quem não divide servidor (erro 50278, "no mutual guilds"), e instalar o app na conta do cliente é o que dispensa esse servidor em comum. - O módulo guarda apenas o user id — sem access token, sem refresh token, sem e-mail. O id é tudo que um DM precisa, e um token guardado aqui seria uma credencial da conta Discord de alguém dentro de um banco de faturamento.
O state não é enfeite: sem ele, um atacante entrega ao cliente logado um link
de autorização preparado e amarra o próprio Discord na conta da vítima — daí
em diante as faturas e os avisos de troca de senha dela chegam no DM dele.
Uma superfície por estado, nunca as duas ao mesmo tempo:
| Estado | O que aparece |
|---|---|
| não vinculado | notificação fixada no topo (ClientAlert) — o convite, onde o cliente já está |
| vinculado | a notificação some; o card na home diz "Recebendo em fulano" |
| sempre | item Notificações na sidebar — é o caminho de VOLTA, não um pedido |
As duas juntas diriam a mesma coisa duas vezes na mesma tela, e alerta que nunca
sai é alerta que se aprende a rolar por cima. bodyHtml do card vai DENTRO do
array do addChild — setá-lo depois, no Item devolvido, deixava o card com
título e botão e nenhum texto.
Login com Discord, cargos e guild
As Sign-In Integrations do WHMCS são fechadas em Facebook, Google e X — não há
API para um módulo registrar um provedor. Então o login não finge ser uma:
autentica a pessoa contra a tabela de vínculos e pede ao WHMCS que a logue por
CreateSsoToken (token de 60s → /oauth/singlesignon.php), que é a forma
suportada de logar um cliente programaticamente.
O registro não cria a conta. AddClient exige endereço, cidade, país —
campos que o Discord não tem e que o módulo teria que inventar; inventar produz
um cadastro errado desde o primeiro dia e uma fatura que ninguém consegue emitir.
Em vez disso a pessoa vai ao formulário de registro do WHMCS com nome e e-mail
preenchidos, e o Discord é anexado sozinho quando o cadastro conclui
(ClientAdd consome a identidade parada na sessão — uma vez, senão a máquina
compartilhada de um revendedor amarraria o mesmo Discord na próxima conta).
Cargos: o bot é o CONECTOR. Nada de segundo bot. O conector Discord do
UnifyDesk já está logado na guild, então ele ganhou três comandos
(list_guild_roles, join_guild, sync_roles) e o módulo os chama por
POST /api/extensions/:id/command — por isso o token de API precisa também de
extensions.write.
A matriz é a do Andrezzz: visitante, cliente, grupo de cliente, produto, grupo de
produto e servidor do produto, com keep_on_suspend (serviço suspenso costuma
ser fatura em aberto, não saída). Duas regras que importam:
- Só removemos o que é nosso:
remove = cargos_das_regras − cargos_devidos. Cargo de moderador, cor conquistada, cargo de outra integração — nada disso é tocado. Tirar seria o módulo vandalizando o servidor. - Não há lista separada de "guilds para entrar": são as que as regras já nomeiam. Duas listas seriam duas chances de discordarem.
Sincroniza sozinho quando um serviço muda de estado e quando o cliente vincula. O desvínculo retira os cargos antes de apagar a linha — depois não há mais Discord id para endereçar, e o ex-cliente ficaria com o crachá de pagante para sempre.
Decisões que valem uma linha:
- Nada em
tbladdonmodules. WHMCS cifra os campospassworddeclarados em_config()e só decifra ao renderizar a própria tela: o token funcionaria na página de configuração e em lugar nenhum — nem num hook, nem no cron. - Payload lido do BANCO, não do hook. Cada hook de ticket entrega um conjunto diferente de chaves, e os nomes mudam entre versões do WHMCS. O id é a única coisa que todos dão.
- Fila por padrão.
InvoicePaidroda dentro do callback do gateway; segurar esse callback por 15s enquanto o painel responde é como um pagamento vira "falhou" e é retentado. skippedé um resultado, ao lado desentefailed: "evento desligado", "cliente sem telefone" e "o painel recusou" têm consertos diferentes.- Um aviso não abre ticket por padrão — "sua fatura foi paga" é anúncio, e
arquivá-lo como ticket põe na fila de atendimento um item que ninguém vai
responder. Por evento dá para inverter (
Abrir conversa), e aí ele usaPOST /api/tickets/startem vez dePOST /api/tickets/notify.
Idiomas
Duas superfícies, dois donos, dois jeitos de nomear locale — de propósito, cada uma segue a convenção do host que a resolve:
| Arquivos | Quem escolhe | |
|---|---|---|
| Página da conexão | langs/{pt_BR,en_US,es_ES,fr_FR,de_DE,it_IT}.json |
o operador do painel |
| Módulo WHMCS | php/unifydesk/lang/{portuguese-br,english,spanish,french,german,italian}.php |
o admin (tela) e o CLIENTE (mensagem) |
O módulo já usa os seis: Lang::forClient lê tblclients.language, então o
aviso de fatura sai no idioma de quem vai ler. Idioma novo é arquivo, não
código — normalize() sanitiza para [a-z-] e confere is_file, e um pedido
que não casa exato cai na mesma língua de outra região (portuguese-pt →
portuguese-br) antes de cair no inglês.
A página da conexão, porém, só aparece nos idiomas que o painel oferece —
locales() do i18n-service lê langs/*.json da RAIZ do repo, hoje só pt_BR
e en_US. Os outros quatro já estão traduzidos e entram no ar no dia em que o
dicionário do painel ganhar o locale correspondente.
__tests__/module-langs.test.ts guarda os dois jeitos de quebrar isso em
silêncio: chave que existe num idioma só, e template que usa variável que o
evento não fornece — foi o que pegou {$invoice_url} no aviso de serviço
suspenso, que renderizava vazio e deixava a frase terminando em dois-pontos.
O que NÃO está aqui
- Fechar no painel não fecha no WHMCS. O host ainda não emite
ticket.close/ticket.updatepara extensions (o protocolo declara, nada dispara), então o status viaja só de lá para cá, como propriedade do ticket. - Consentimento (opt-in/opt-out) por cliente. O corte hoje é por evento e por canal; LGPD por contato é a próxima passada.
- Anexo de saída depende do WHMCS alcançar o painel:
Bridge\Serverbaixa a URL assinada e entrega os bytes aoAddTicketReply. O que não baixar é NOMEADO no corpo da resposta, nunca sumido em silêncio.