Os nomes de usuário poderiam ser incluídos no payload do webhook user_badge?

Existe alguma possibilidade de os webhooks de user_badge incluírem o username junto com o user_id? Atualmente, o payload de um evento de user_badge parece com o seguinte:

{
  "user_badge": {
    "id": 123456,
    "granted_at": "2026-09-25T14:54:42.073Z",
    "created_at": "2026-09-26T05:34:12.965Z",
    "post_id": 234567,
    "post_number": 1,
    "badge_id": 123,
    "user_id": 456,
    "granted_by_id": -1,
    "topic_id": 56789
  }
}

Obter o username para um user_id (e para o granted_by_id, caso não seja -1) requer uma chamada adicional para /admin/users/{user_id}.json, o que não apenas exige requisições extras, mas também requer privilégios de administrador, pelo menos até onde eu pude verificar.

Existe uma maneira de obter o username para um user_id específico usando uma chave de API sem precisar de privilégios de administrador?

Estou criando um serviço externo que eu esperava disparar quando badges específicos fossem concedidos; no entanto, ele precisa do username e os webhooks de badges retornam apenas IDs de usuário. :confused:

A título de informação, minha solução alternativa para webhooks não padrão ou quando preciso de dados adicionais é fazer isso por meio de Workflows e, em seguida, enviar isso para o n8n.

Só para dizer, obter o nome de usuário a partir do ID do usuário é apenas uma chamada de API. A menos que você esteja concedendo manualmente milhares de distintivos instantaneamente, você deve estar bem dentro dos limites padrão da API.

Obrigado! Vou dar uma olhada nos Workflows.

Que bom saber! Você poderia fornecer detalhes sobre essa chamada de API que não é /admin/users/{user_id}.json (que requer privilégios de administrador)? Não consegui encontrar um endpoint para fazer isso com uma chave de API que não exigisse privilégios de administrador.

Qual é o problema com uma chave de API de administrador? Isso deveria ser um job em background, né?

Princípio do Menor Privilegio. Este serviço externo será executado em outro servidor e só precisa de acesso de leitura ao Discourse para realizar sua função. Quanto menos chaves mestras circulando pela infraestrutura, melhor.

Isso levanta o ponto interessante de por que não existe um endpoint público para buscar um usuário por ID que mostre apenas os campos públicos, caso login_required não esteja selecionado. Seria útil, não? Me pergunto por que isso não foi feito. Ou talvez apenas usuários conectados pudessem ter esse privilégio, mas, de qualquer forma, deveria haver alguma maneira de acessá-lo sem usar o endpoint de administrador…

@burke Uma solução alternativa mais trabalhosa seria olhar para /groups/trust_level_0/members.json (ou qualquer grupo mais específico) e encontrar o usuário pelo ID, já que essa rota também contém o nome de usuário.

Isso ocorre porque, em todos os aspectos e propósitos, os IDs de usuário são essencialmente uma convenção interna no discourse, e espera-se que confiemos nos nomes de usuário, que são muito mais proeminentes em geral.

O que nos traz de volta à solicitação original. O webhook deve seguir esse conceito e enviar o nome de usuário, não o ID do usuário.

Concordo! Isso parece ser a melhor solução. Adicionar username e granted_by_username e descontinuar user_id e granted_by_id poderia permitir um período de transição sem quebras.

Atualmente, se um distintivo for alterado pelo sistema (ou seja, automaticamente), o webhook retorna um granted_by_id de -1. Qual seria o equivalente para granted_by_username?

  • "" (string vazia)
  • "<system>" (um valor mágico que não poderia ser um nome de usuário)
  • null
  • Nada – ou seja, omitir granted_by_username do payload se for um evento impulsionado pelo sistema

Eu presumiria que null ou nada fariam mais sentido.

Na verdade, -1 é um ID de usuário válido que possui um nome de usuário (geralmente system) associado a ele, assim como qualquer outro usuário. Não há motivo para tratá-lo de forma diferente.

Aqui no meta https://meta.discourse.org/u/system/summary

Ah. Obrigado por me ensinar isso. Então, presumivelmente, o granted_by_username seria apenas "system" para alterações automatizadas.

@putty Tenho pesquisado sobre Workflows. Até que (ou a menos que) os eventos de badge incluam nomes de usuário (e talvez independentemente disso, já que posso usar filtros nos Workflows para enviar apenas as alterações de que meu serviço precisa), isso parece ser uma ótima opção alternativa! Obrigado novamente!

Com webhooks, há um X-Discourse-Event-Signature com uma assinatura HMAC-SHA256 do payload usando um segredo que me permite verificar o remetente. Existe alguma chance de haver uma maneira de assinar requisições HTTP (POSTs) dos Workflows? Talvez eu simplesmente ainda não tenha encontrado. Eu esperava que houvesse uma configuração como “Assinar requisição usando segredo” que eu pudesse ativar e fornecer um segredo. O mais próximo que vi é adicionar uma credencial header_auth para enviar um segredo em um cabeçalho (por exemplo, X-Discourse-Event-Secret: Discourse Rocks!). Isso é melhor do que nada, mas uma assinatura usando um segredo (em vez de enviar o próprio segredo) seria preferível.

Infelizmente, também me baseei no envio do segredo por meio de um cabeçalho. Talvez @j.jaffeux possa sugerir uma alternativa melhor, que eu adotarei prontamente também :sweat_smile:

Criei uma etapa de código para calcular uma assinatura. Existe uma Web Crypto API que pode calcular um HMAC-SHA256 muito mais facilmente, mas ela usa uma promise e isso não funciona dentro de um eval. Então, pedi para uma IA criar um método nativo em JavaScript para calcular HMAC-SHA256, adicionei uma variável com a chave secret e o segredo como seu valor (, e acabei com um script de Código como este:

// Variáveis disponíveis:
//   $input.item.json       - dados JSON do item atual
//   $input.all()           - array de todos os itens de entrada
//   $json                  - atalho para $input.item.json
//   $("NodeName").item     - acessar o item pareado de outro nó
//   $vars.KEY              - variáveis de fluxo de trabalho
//   $site_settings.NAME    - configurações do site
//   $execution             - metadados de execução (id, workflow_name, ...)
//   $current_user          - usuário executando o fluxo de trabalho
//   $helpers.absoluteUrl(path) - converter um caminho relativo em uma URL absoluta
//   console.log/warn/error - log

function hmacSha256(message, key) {
  function utf8Bytes(str) {
    const bytes = [];

    for (let i = 0; i < str.length; i++) {
      let code = str.charCodeAt(i);

      // Lidar com pares de substitutos (surrogate pairs)
      if (
        code >= 0xd800 &&
        code <= 0xdbff &&
        i + 1 < str.length
      ) {
        const next = str.charCodeAt(i + 1);

        if (next >= 0xdc00 && next <= 0xdfff) {
          code =
            0x10000 +
            ((code - 0xd800) << 10) +
            (next - 0xdc00);
          i++;
        }
      }

      if (code <= 0x7f) {
        bytes.push(code);
      } else if (code <= 0x7ff) {
        bytes.push(
          0xc0 | (code >> 6),
          0x80 | (code & 0x3f)
        );
      } else if (code <= 0xffff) {
        bytes.push(
          0xe0 | (code >> 12),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      } else {
        bytes.push(
          0xf0 | (code >> 18),
          0x80 | ((code >> 12) & 0x3f),
          0x80 | ((code >> 6) & 0x3f),
          0x80 | (code & 0x3f)
        );
      }
    }

    return bytes;
  }

  function rotr(x, n) {
    return (x >>> n) | (x << (32 - n));
  }

  function sha256(bytes) {
    const K = [
      0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
      0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
      0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
      0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
      0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
      0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
      0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
      0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
      0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
      0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
      0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
      0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
      0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
      0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
      0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
      0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
    ];

    const H = [
      0x6a09e667,
      0xbb67ae85,
      0x3c6ef372,
      0xa54ff53a,
      0x510e527f,
      0x9b05688c,
      0x1f83d9ab,
      0x5be0cd19
    ];

    const data = bytes.slice();
    const bitLength = data.length * 8;

    // Preenchimento SHA-256
    data.push(0x80);

    while ((data.length % 64) !== 56) {
      data.push(0);
    }

    // Anexar o comprimento da mensagem de 64 bits em big-endian.
    // Inteiros JS são seguros aqui para tamanhos normais de payload de fluxo de trabalho.
    const high = Math.floor(bitLength / 0x100000000);
    const low = bitLength >>> 0;

    data.push(
      (high >>> 24) & 0xff,
      (high >>> 16) & 0xff,
      (high >>> 8) & 0xff,
      high & 0xff,
      (low >>> 24) & 0xff,
      (low >>> 16) & 0xff,
      (low >>> 8) & 0xff,
      low & 0xff
    );

    const w = new Array(64);

    for (let offset = 0; offset < data.length; offset += 64) {
      for (let i = 0; i < 16; i++) {
        const j = offset + i * 4;

        w[i] =
          ((data[j] << 24) |
            (data[j + 1] << 16) |
            (data[j + 2] << 8) |
            data[j + 3]) >>> 0;
      }

      for (let i = 16; i < 64; i++) {
        const s0 =
          rotr(w[i - 15], 7) ^
          rotr(w[i - 15], 18) ^
          (w[i - 15] >>> 3);

        const s1 =
          rotr(w[i - 2], 17) ^
          rotr(w[i - 2], 19) ^
          (w[i - 2] >>> 10);

        w[i] =
          (
            w[i - 16] +
            s0 +
            w[i - 7] +
            s1
          ) >>> 0;
      }

      let a = H[0];
      let b = H[1];
      let c = H[2];
      let d = H[3];
      let e = H[4];
      let f = H[5];
      let g = H[6];
      let h = H[7];

      for (let i = 0; i < 64; i++) {
        const S1 =
          rotr(e, 6) ^
          rotr(e, 11) ^
          rotr(e, 25);

        const ch = (e & f) ^ (~e & g);

        const temp1 =
          (
            h +
            S1 +
            ch +
            K[i] +
            w[i]
          ) >>> 0;

        const S0 =
          rotr(a, 2) ^
          rotr(a, 13) ^
          rotr(a, 22);

        const maj =
          (a & b) ^
          (a & c) ^
          (b & c);

        const temp2 = (S0 + maj) >>> 0;

        h = g;
        g = f;
        f = e;
        e = (d + temp1) >>> 0;
        d = c;
        c = b;
        b = a;
        a = (temp1 + temp2) >>> 0;
      }

      H[0] = (H[0] + a) >>> 0;
      H[1] = (H[1] + b) >>> 0;
      H[2] = (H[2] + c) >>> 0;
      H[3] = (H[3] + d) >>> 0;
      H[4] = (H[4] + e) >>> 0;
      H[5] = (H[5] + f) >>> 0;
      H[6] = (H[6] + g) >>> 0;
      H[7] = (H[7] + h) >>> 0;
    }

    const result = [];

    for (let i = 0; i < H.length; i++) {
      result.push(
        (H[i] >>> 24) & 0xff,
        (H[i] >>> 16) & 0xff,
        (H[i] >>> 8) & 0xff,
        H[i] & 0xff
      );
    }

    return result;
  }

  function bytesToHex(bytes) {
    return bytes
      .map(b => b.toString(16).padStart(2, "0"))
      .join("");
  }

  const blockSize = 64;

  let keyBytes = utf8Bytes(key);
  const messageBytes = utf8Bytes(message);

  // Chaves maiores que o tamanho do bloco SHA-256 são primeiro hasheadas.
  if (keyBytes.length > blockSize) {
    keyBytes = sha256(keyBytes);
  }

  // Preencher a chave até 64 bytes.
  while (keyBytes.length < blockSize) {
    keyBytes.push(0);
  }

  const innerPad = new Array(blockSize);
  const outerPad = new Array(blockSize);

  for (let i = 0; i < blockSize; i++) {
    innerPad[i] = keyBytes[i] ^ 0x36;
    outerPad[i] = keyBytes[i] ^ 0x5c;
  }

  const innerHash = sha256(
    innerPad.concat(messageBytes)
  );

  const hmac = sha256(
    outerPad.concat(innerHash)
  );

  return bytesToHex(hmac);
}

const payload = JSON.stringify({
    "the-json-payload": "goes-here"
});

const signature = "sha256=" + hmacSha256( payload, $vars.secret);

return {
    "payload": payload,
    "signature": signature
};

Isso é seguido por uma etapa de requisição HTTP com um cabeçalho com a chave X-Discourse-Workflow-Secret e o valor {{ $("Code").item.json.signature }}, tipo de conteúdo JSON e corpo da requisição {{ $("Code").item.json.payload }}.

O resultado é uma requisição HTTP como esta:

User-Agent: Faraday v2.14.4
Content-Length: 41
Accept: */*
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Content-Type: application/json
X-Discourse-Workflow-Secret: sha256=9ad2820b545013bdd18d4e7e6139624b91f576498e528201fa7759ec58dd598c

{"the-json-payload": "goes-here"}

O X-Discourse-Workflow-Secret é uma assinatura HMAC-SHA256 usando o valor do argumento secret… assim como um webhook assinado! :slight_smile:

Pode não ser bonito, mas parece estar funcionando.

Isso parece bem legítimo! Obrigado por compartilhar!

Só para constar, testei essa implementação nativa de HMAC-SHA256 contra a Web Crypto API para confirmar que elas produzem os mesmos resultados para mensagens e chaves aleatórias, e as assinaturas corresponderam. Não posso garantir que não existam casos especiais em que elas divergem, mas estou satisfeito o suficiente para usá-la em produção. :slightly_smiling_face: