@unifydesk/extension-paymenter
Tickets do Paymenter dentro do UnifyDesk, em tempo real e nos dois sentidos — sem polling e sem entregar uma admin API key para a conversa.
PORT=3020 bun extensions/paymenter/src/server.ts
Como funciona
O Paymenter não emite webhook de ticket. A alternativa era o conector varrer
/api/v1/admin/tickets de tempos em tempos: resposta atrasada, requisição
constante mesmo sem novidade, e uma chave de admin (que abre a instalação
inteira) guardada por um terceiro processo.
Então a ponte é uma extension do próprio Paymenter — php/Unifydesk.php,
tipo Other. Ela escuta os eventos de domínio (Ticket\Created,
TicketMessage\Created, Ticket\Updated) e faz POST no endereço público desta
conexão; a resposta do atendente volta por POST /extensions/unifydesk/reply,
que a mesma extension serve. Os dois sentidos são assinados com HMAC-SHA256
sobre os bytes crus de uma única chave compartilhada.
Paymenter --(evento de domínio)--> POST <painel>/api/extensions/paymenter/<conn>/hook
Paymenter <--(resposta do agente)-- POST <paymenter>/extensions/unifydesk/reply
Instalar
Tudo pela tela da conexão (aba de configuração da conexão no painel):
-
URL do Paymenter — salvar.
-
Baixar
unifydesk.zip— sai já com o endereço desta conexão e a chave compartilhada gravados no próprio código:unifydesk.zip └── Unifydesk/ └── Unifydesk.phpO pacote vem por RPC (host→extension, autenticado) e o navegador monta o arquivo a partir do base64. Não passa pelo relay público: ele lê a resposta da extension como TEXTO, então os bytes do zip chegariam corrompidos — e a chave compartilhada nunca precisa viajar numa URL sem credencial.
-
Descompactar dentro de
extensions/Others/na instalação do Paymenter e instalar Unifydesk em Extensions → Other. Não há nada a preencher: endereço e chave não são campos do formulário, são constantes gravadas no download. Pedi-los de novo seria pedir ao admin que copiasse à mão dois valores que o sistema já conhece — e um caractere errado na chave produz uma ponte que só se manifesta como 401.O que o formulário do Paymenter mostra é o ESTADO (endereço gravado) mais o que é decisão local: o usuário autor das respostas e se manda mudança de status.
A pasta, o arquivo e a classe têm o mesmo nome porque é assim que o Paymenter
descobre a extension (PaymenterExtensionsOthersUnifydeskUnifydesk).
O botão Verificar instalação chama GET /extensions/unifydesk/ping lá. Sem
a ponte instalada a conexão fica em needs_auth (bridge_not_installed) — é
passo pendente de uma pessoa, não falha de rede, então o supervisor não fica
retentando nem pinta vermelho com backoff eterno.
Gerar nova chave rotaciona o segredo: o arquivo já instalado passa a ser
recusado (401 → shared_secret_mismatch) até baixarem de novo.
O que atravessa
| Paymenter | UnifyDesk |
|---|---|
| ticket aberto | ticket com referência ticket:<id>, propriedades paymenter_* |
| mensagem do cliente | mensagem de ENTRADA (dispara automação/bot, conta não-lida) |
| mensagem do staff (feita no admin do Paymenter) | mensagem de SAÍDA já enviada |
| mudança de status | propriedade paymenter_status do ticket |
| resposta do atendente no UnifyDesk | TicketMessage no ticket, status → replied |
Anexos viajam inline em base64 (até 8 MB): o arquivo no Paymenter fica atrás da sessão do cliente, então uma URL seria um anexo que o atendente não abre.
Eco: a extension marca UnifyDesk::$suppress enquanto grava a resposta que veio
do painel — sem isso cada resposta voltaria para quem a mandou.
Config
Nível extension (global ao processo) e nível conexão (sobrescreve):
apiUrl— endereço do Paymenter. É o único campo que se digita.webhookSecret— gerado automaticamente na conexão, persistido viasaveConfig. Não é para digitar.verifyInstall— checar a ponte no start (desligue se o Paymenter não for alcançável a partir do processo).
Não há API key. A admin API key do Paymenter abre a instalação inteira
(usuários, faturas, servidores) e ficaria guardada neste processo só para um
block responder "qual é o cliente desse e-mail". Toda leitura passa pela mesma
ponte assinada, em POST /extensions/unifydesk/query, cuja lista de operações é
fechada e somente-leitura — o teto do canal é o que os blocks realmente pedem.
Blocks de automação
Todos consultam pela ponte (op → operação declarada no PHP), com teto de 25
linhas por resposta: um block não pagina.
action.paymenter_customer— cliente por e-mail ou id (op: customer).action.paymenter_services— serviços do cliente (op: services).action.paymenter_invoices— faturas do cliente, com status opcional (op: invoices).
Avisos de faturamento (a segunda metade)
Espelho do que a extension do WHMCS faz, e independente da ponte: os avisos saem pela API REST do painel, então uma instalação sem chave compartilhada ainda os manda, e uma sem token de API ainda espelha tickets.
Oito eventos do Paymenter, cada um com interruptor, canais e texto próprios:
| Grupo | Eventos |
|---|---|
| Faturas | criada, paga, fechada |
| Serviços | criado, suspenso, reativado, cancelado |
| Conta | cliente novo |
Service\Updated cobre três deles porque o Paymenter não tem evento de
suspensão: a coluna de status mudando é o evento, e ler getChanges() é o
único jeito de saber qual dos três aconteceu. Avisar em todo save diria ao cliente
que o serviço dele mudou porque alguém corrigiu um typo no rótulo.
Tudo roda afterResponse(). Invoice\Paid dispara dentro do callback do
gateway, e segurar esse callback enquanto o painel responde é como um pagamento
vira "falhou" e é retentado.
Destinatário: do CLIENTE, canonizado pelo CONECTOR
Nada na config diz como falar com um cliente. A config diz qual conexão do UnifyDesk é o WhatsApp, qual é o Discord e qual é o e-mail. O endereço vem do cliente:
| Canal | De onde sai |
|---|---|
| propriedade do cliente — o Paymenter não tem campo de telefone | |
| Discord | o que o CLIENTE autorizou (OAuth), nunca um campo |
E-mail não é canal do UnifyDesk. O Paymenter já manda e-mail — mailer, templates e fila dele — e passar isso por um conector daria à instalação dois sistemas que se acham donos da caixa do cliente. A página Notificar continua oferecendo e-mail; ela usa o do Paymenter.
Só o operador sabe como nomeou a propriedade do telefone, então a área admin
pergunta a CHAVE dela. O valor cru passa por POST /api/tickets/resolve-target — a mesma
chamada do "novo atendimento" do painel — e o que sai é a referência canônica,
que é a que o inbound carrega. Mandar para outra grafia abre um SEGUNDO ticket
quando o cliente responde. O resultado fica em cache por uma hora: é o cliente
que muda o próprio cadastro, não nós.
Os três estados do contrato ficam separados: exists:true vale; error ou
exists:false pula nomeando o motivo; exists ausente é não verificado e
NÃO barra o envio — recusar numa consulta que não pôde rodar para mensagens a
clientes perfeitamente alcançáveis.
Nenhuma frase escrita na tela
As três páginas pedem CHAVE, nunca texto: Lang::t('notify-send') no PHP,
__('unifydesk::admin.notify-send') no blade. O dicionário mora em
resources/lang/{en,pt}/admin.php e o inglês é a FONTE, como no resto do
Paymenter — o admin dele traduz para fora, e uma extension que crava outro
idioma na marcação renderiza português dentro de um painel em inglês, sem lugar
onde traduzir depois. Idioma novo é PASTA, não código.
O namespace é registrado dentro do próprio Lang::t (na primeira chamada), e
não no boot(), porque getConfig() é renderizado também para extension
DESABILITADA — e extension desabilitada não dá boot. Registrar lá deixaria
aquela tela imprimindo unifydesk::admin.config-agent para o operador, o que se
lê como instalação quebrada.
O rótulo do evento saiu do Catalog: era a segunda cópia do mesmo nome, e duas
cópias divergem. extensions/__tests__/php-packages.test.ts guarda os dois
sentidos — chave que o código pede e não existe, e chave que existe num idioma
só (inclusive as montadas, event-* e group-*, que vêm do catálogo).
Área "UnifyDesk" no admin
Página Filament em Admin/Pages/, que o Paymenter auto-registra para toda
extension habilitada (discoverPages no AdminPanelProvider). Aparece em
Extensions → UnifyDesk — o grupo onde o Paymenter arquiva o que configura
uma extension; estava em Administration, ao lado de Users, que é onde se vai
para trabalhar num CLIENTE. Três abas: Conexão, Canais e Eventos.
Existe porque o getConfig() de uma extension é uma lista PLANA de campos —
suficiente para uma URL e uma chave, inútil para oito eventos com canal e corpo
de mensagem cada. Uma página agrupa, e agrupar é toda a diferença entre uma tela
que se configura e uma tela que se rola.
O que ela grava vai nos MESMOS settings que Extension::config() lê no boot().
Gravar em outro lugar significaria a tela salvando num sítio e os listeners lendo
de outro — com "salvei e não aconteceu nada" como sintoma.
Discord: quem vincula é o cliente
O Paymenter tem login social com Discord — e descarta o id.
SocialLoginController::handle() lê $oauth_user->email, casa a conta por
e-mail e joga fora o resto: não há coluna, não há tabela e não há evento
carregando o snowflake. Ou seja, "usa o id do login social" não tinha de onde
ler.
Então a autorização é pedida de novo por nós (Discord/OAuth.php), com o MESMO
app que o operador já configurou no Paymenter (settings.oauth_discord_*) — um
segundo app seria um segundo client secret para guardar, pelo mesmo botão na
mesma área de cliente. São dois passos no Developer Portal: adicionar
https://<paymenter>/unifydesk/discord/callback como segunda redirect URI, e
ligar User Install em Installation Contexts — sem ele o Discord recusa a URL
de autorização.
Escopo identify guilds.join applications.commands, com integration_type=1.
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.
guilds.join continua junto porque a guild ainda é onde as regras de cargo
entregam o que o cliente comprou — duas rotas independentes para a mesma caixa
de entrada é a diferença entre fatura atrasada e fatura que nunca chega.
Guarda só o id e o handle
(Discord/Links.php, como propriedade do cliente, chave FIXA
unifydesk_discord_id). Sem access token, sem refresh, sem e-mail: o id é tudo
que um DM precisa, e um token do Discord num banco de faturamento é credencial
que ninguém pediu para a gente ter. O state é obrigatório — sem ele, um link
de autorização preparado amarra o Discord do ATACANTE na conta da vítima, e daí
em diante as faturas dela chegam no privado dele.
A entrada fica no menu de conta do cliente (evento navigation.account), e a
ficha do cliente no admin mostra o estado.
Discord: cargo por serviço, e entrar no servidor junto
Vincular passou a valer mais do que um endereço de DM. Aba Discord em Extensions → UnifyDesk:
- Regras de cargo (
Discord/Rules.php):(quando, o quê, servidor, cargo)— "quem tem produto da categoria 3 ganha o cargo R no servidor G". Os tipos são as perguntas que um negócio de hospedagem realmente faz (é cliente? tem este produto? tem produto desta categoria? não é cliente?), então a regra se escreve nos termos do operador em vez de numa lista de snowflakes na mão. Ficam numa linha de settings (JSON), como os eventos: tabela exigiria migration para shippar, reverter no uninstall e responder por ela em todo upgrade, para meia dúzia de linhas de configuração. - É SYNC, não concessão: só cargo citado nas regras é REMOVIDO quando o cliente deixa de ter direito. Cargo de moderador, cor que alguém ganhou, cargo de outra integração não são da nossa conta — tirá-los porque um serviço venceu seria a extension vandalizando o servidor.
- Entrar no servidor (
Discord/Guilds.php, chavediscord_auto_join): no callback do OAuth o cliente é puxado para os servidores que as regras citam, JÁ com o que ele tem direito. O escopo virouidentify guilds.join— o bot não consegue puxar ninguém sozinho, e pedir o consentimento numa segunda tela é como um fluxo de dois cliques vira uma thread de suporte de dois dias. O access token vive só dentro da requisição: é usado uma vez e não é gravado. - Sem bot próprio (
Discord/Bot.php): quem executa é o conector Discord do UnifyDesk, que já é um bot logado no servidor do operador e já declaralist_guild_roles,join_guildesync_roles. Um token nosso seria um segundo app, um segundo segredo num banco de faturamento e um segundo processo vivo, para o que o primeiro já faz. Chamada viaApi\Client::command()→POST /api/extensions/:id/command. - Quando sincroniza: serviço criado, status mudou (suspenso/reativado/
cancelado) e no instante do vínculo. Sempre
syncQuietly— cargo é efeito colateral de faturamento, e o Discord fora do ar não pode derrubar o checkout que pagou por ele. Desvincular devolve exatamente os cargos gerenciados, antes de o id sumir (depois não há mais com o que desfazer).
O quadro de cargos do painel mostra cargo que o bot NÃO consegue conceder (acima do cargo dele) em vez de escondê-lo: "Missing Permissions" no log se lê como módulo quebrado, e o motivo só é conhecível ali.
Entrar com Discord
GET /unifydesk/discord/login (chave discord_login) — coloque como link na sua
tela de login. Quem já vinculou entra direto; o resto do fluxo:
- A identidade é o VÍNCULO, não o e-mail. E-mail só casa uma conta existente na PRIMEIRA vez, e só quando o Discord diz que é verificado — endereço não verificado é campo que a pessoa digitou, e aceitá-lo entrega qualquer conta cujo e-mail alguém consiga pôr num Discord descartável.
- Discord desconhecido com
discord_registerligado vai para o cadastro do PRÓPRIO Paymenter com nome e e-mail preenchidos; o vínculo (e a entrada no servidor) acontece emUser\Created. Criar a conta aqui seria inventar senha e campos que o formulário do Paymenter é dono de decidir. - O callback saiu de trás do
auth(não há ninguém logado nesse fluxo) e o MODO vem da SESSÃO, nunca da query string: ele decide se a requisição pode logar alguém, e isso não pode depender de um valor que o navegador controla. - Quem já está logado sempre LINKA, venha de onde vier — logar de novo numa conta em que a pessoa já está é no melhor caso no-op e no pior uma troca de quem ela é.
Página "Notificar" — uma mensagem, muitos clientes
Segunda página Filament (Administration → Notificar). O Paymenter não tem área de disparo em massa: quem quisesse avisar de uma manutenção escrevia uma query ou dizia a mesma coisa em quatro lugares. É a ideia do "Mass Mail" do WHMCS com os canais que o UnifyDesk acrescenta, e mora aqui — não no Paymenter — porque só esta extension sabe se existe WhatsApp configurado.
Público como PERGUNTA, nunca como filtro livre: todos, com serviço ativo, sem
serviço ativo, com fatura em aberto, clientes escolhidos. Filtro livre é mais
poderoso e é também o que manda e-mail para quatro mil pessoas sem querer. O
público é sempre um Builder — a contagem na tela e o envio saem da MESMA
query, então o número que o operador leu é o número que recebe.
"Clientes escolhidos" é um select com BUSCA por nome ou e-mail, do jeito que o Paymenter busca usuário em qualquer outra tela. Digitar id significa abrir outra aba antes, e um id errado é uma mensagem para quem nunca deveria recebê-la.
Mensagens por minuto é campo da tela (padrão 10) e vale para WhatsApp e Discord. Mandar tudo de uma vez é o que faz uma plataforma ler o envio como spam — e a punição não é a mensagem recusada, é a conta bloqueada. O espaçamento sai do MESMO marcador que os avisos de faturamento usam: dois agendadores num número só é a rajada que nenhum dos dois quis mandar.
Antes do clique a tela diz três coisas, porque disparo não tem desfazer: quantos recebem, quanto TEMPO leva (dois mil clientes a 10/min são mais de três horas) e quais canais conseguem entregar agora. Canal sem conexão não aparece — checkbox que entrega em silêncio para ninguém é pior que checkbox ausente.
O aviso no painel é uma linha no banco (sem job); o e-mail vai pelo mailer do Paymenter; WhatsApp e Discord entram na fila um job por destinatário por canal, cada um com o próprio horário reservado.
Espaçamento: o cron não pode virar rajada
Support/Throttle.php. O WhatsApp é a razão: um número que dispara duzentas
mensagens num minuto é lido como spam, e a punição não é a mensagem recusada — é
a linha inteira banida, levando junto todas as conversas. O cron do Paymenter
gera faturas em laço, então Invoice\Created sai uma vez por cliente, coladas.
Throttle::reserve($canal) devolve o atraso do próximo horário livre e move o
marcador atomicamente (Cache::lock) — sem o lock dois workers leem o mesmo
marcador e disparam juntos, que é exatamente a rajada que isto evita. Padrão: 6s
no WhatsApp, 2s no Discord, configurável em Canais. E-mail não passa por aqui (o
transporte dele já tem limite próprio).
UnifyDesk na ficha do cliente
Admin/Pages/ClientChannels.php — uma página na SUB-NAVEGAÇÃO do cliente, ao
lado de Invoices, Services e Tickets, com a entrada injetada por render hook
(panels::page.sub-navigation.sidebar.after, escopado às páginas do
UserResource): UserResource é classe do Paymenter e
getRecordSubNavigation() é método dela, então não há o que estender.
Era um bloco grudado no TOPO do formulário de edição, e isso estava errado duas vezes: empurrava o título da própria página para baixo, e respondia uma pergunta que não é "editar este usuário". Todo o resto que é assunto PRÓPRIO do cliente já é item daquela rail.
Responde duas perguntas que o resto do admin não responde:
- Esse cliente é alcançável? Um telefone que não é WhatsApp é invisível em qualquer outra tela: o cadastro parece normal e as mensagens simplesmente não chegam. A resposta fica ao lado do número, onde está quem pode corrigir.
- Falar com ele. Enviar mensagem é ação do HEADER, com modal do próprio
Filament (canal + texto) — a caixa inline dentro do card era um formulário
improvisado no meio de uma tela que tem um jeito de fazer isso. Abrir no
UnifyDesk leva à conversa (
/tickets/<ref>resolve pela referência do contato); o wa.me existe ao lado, mas só para OLHAR o número: responder por ali sairia do celular de quem clicou, fora do histórico.
Três estados, separados de propósito: verificado (o conector confirmou), não entrega (o conector recusou, em vermelho) e não verificado (neutro: a checagem não pôde rodar). Pintar o terceiro de vermelho ensina o atendente a ignorar o vermelho que importa.
Arquivos
php/Unifydesk/— fonte canônica da extension do Paymenter. Editar aqui; a cópia dentro de uma instalação é o download.Unifydesk.php— ponte de tickets, rotas, listeners de faturamentoAdmin/Pages/— as páginas que o Paymenter auto-registra (UnifyDesk, Notificar e a do cliente)Discord/— OAuth (vínculo e login), o id que ele guarda, as regras de cargo, a entrada no servidor e o bot por RPC no conectorApi/Notify/Support/— cliente REST, catálogo/decisão/entrega/público, settings e espaçamento
src/php.ts— empacota a árvore e preencheSupport/Install.php.src/runtime.ts— conexão: estado, assinatura, entrada e saída.src/extension.ts— manifest, rota/hook, blocks.frontend/connector-settings.html— a tela dos três passos.