Saltar al contenido
whmcs whmcs

whmcs

Tickets do WHMCS em tempo real (ponte push nos dois sentidos) + notificações por qualquer conector.

About this extension

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
WhatsApp 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álido
  • error / 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 consertar
  • exists ausente → 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:

  1. 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.
  2. 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 (escopo identify guilds.join applications.commands, com integration_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.
  3. 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 campos password declarados 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. InvoicePaid roda 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 de sent e failed: "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 usa POST /api/tickets/start em vez de POST /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.update para 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\Server baixa a URL assinada e entrega os bytes ao AddTicketReply. O que não baixar é NOMEADO no corpo da resposta, nunca sumido em silêncio.

Versions

v1.0.1 hace 1 semana approved
v1.0.0 hace 1 semana approved
Developer
UnifyDesk
Downloads
0
Last updated
octubre 26, 2026
Price
Free
Latest version
1.0.1
Category
Extension
Instances
One per panel

Install

Install this extension from your UnifyDesk panel: Settings → Extensions → Install, then pick it from the Marketplace tab.

Extension id
whmcs