¿Se podrían incluir los nombres de usuario en el payload del webhook user_badge?

¿Hay alguna posibilidad de que los webhooks de user_badge incluyan username junto con user_id? Actualmente, el payload para un evento de user_badge se ve así:

{
  "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
  }
}

Obtener el username para un user_id (y para granted_by_id si no es -1) requiere una llamada adicional a /admin/users/{user_id}.json, lo cual no solo implica solicitudes adicionales, sino que, según entiendo, también requiere privilegios de administrador.

¿Existe alguna forma de obtener el username para un user_id dado usando una clave de API sin necesidad de privilegios de administrador?

Estoy creando un servicio externo que esperaba poder activar cuando se otorguen insignias específicas; sin embargo, necesito el nombre de usuario y los webhooks de insignias solo devuelven identificadores de usuario. :confused:

Por cierto, mi solución alternativa para webhooks no estándar o cuando necesito datos adicionales es hacerlo a través de Workflows y luego enviar eso a n8n.

Por cierto, obtener el nombre de usuario a partir del ID de usuario está a solo una llamada a la API de distancia; a menos que estés otorgando manualmente miles de insignias al instante, deberías estar bien con los límites de la API por defecto.

¡Gracias! Voy a investigar Workflows.

¡Me alegra saberlo! ¿Podrías proporcionar detalles sobre esa llamada a la API que no es /admin/users/{user_id}.json (que requiere privilegios de administrador)? No pude encontrar un punto de acceso para hacer esto con una clave de API que no requiriera privilegios de administrador.

¿Cuál es el problema con una clave de administrador? Esto debería ser un trabajo en segundo plano, ¿no?

Principio de privilegio mínimo. Este servicio externo se ejecutará en otro servidor y solo necesita acceso de lectura a Discourse para realizar su función. Cuantas menos claves maestras circulen por la infraestructura, mejor.

Esto plantea el interesante punto de por qué no existe un punto de acceso público para obtener un usuario por su ID que muestre únicamente los campos públicos, si no se ha seleccionado login_required. ¿No sería útil? Me pregunto por qué no se implementó. O tal vez solo los usuarios iniciados en sesión pudieran acceder a ese privilegio, pero de todas formas, debería haber alguna manera de acceder a él sin recurrir al punto de acceso de administración…

@burke Una solución alternativa más engorrosa sería consultar /groups/trust_level_0/members.json (o cualquier grupo más específico) y buscar al usuario por su ID, ya que esa ruta también contiene el nombre de usuario.

Esto se debe a que, en todos los casos y para todos los propósitos, los IDs de usuario son esencialmente una convención interna en Discourse, y se espera que dependamos de los nombres de usuario, que son mucho más visibles en general.

Lo que nos lleva de vuelta a la solicitud original. El webhook debe seguir ese concepto y enviar el nombre de usuario, no el ID de usuario.

¡De acuerdo! Esto parece la mejor solución. Agregar username y granted_by_username y deprecar user_id y granted_by_id podría permitir un período de transición sin rupturas.

Actualmente, si una insignia es modificada por el sistema (es decir, automáticamente), el webhook devuelve un granted_by_id de -1. ¿Cuál sería el equivalente para granted_by_username?

  • "" (cadena vacía)
  • "<system>" (un valor mágico que no podría ser un nombre de usuario)
  • null
  • Nada, es decir, omitir granted_by_username de la carga si fue un evento impulsado por el sistema

Supongo que null o nada tendría más sentido.

-1 es en realidad un id de usuario válido que tiene un nombre de usuario (normalmente system) asociado, al igual que cualquier otro usuario. No hay razón para tratarlo de manera diferente.

Aquí en meta https://meta.discourse.org/u/system/summary

Oh. Gracias por enseñármelo. Entonces, supongo que granted_by_username simplemente sería "system" para los cambios automatizados.

@putty He estado investigando Workflows. Hasta que los eventos de insignia incluyan nombres de usuario (o incluso independientemente de eso, ya que puedo usar filtros en Workflows para enviar solo por POST los cambios que mi servicio necesita recibir), ¡esto parece una gran alternativa! ¡Gracias de nuevo!

Con los webhooks, hay un X-Discourse-Event-Signature con una firma HMAC-SHA256 de la carga útil usando un secreto que me permite verificar el remitente. ¿Hay alguna posibilidad de que haya una manera de firmar las solicitudes HTTP (POST) desde Workflows? Tal vez simplemente no lo he encontrado todavía. Esperaba que hubiera una configuración como “Firmar solicitud usando secreto” que pudiera activar y proporcionar un secreto. Lo más cercano que he visto es agregar una credencial header_auth para enviar un secreto en un encabezado (por ejemplo, X-Discourse-Event-Secret: ¡Discourse Rocks!). Eso es mejor que nada, pero una firma usando un secreto (sin enviar el propio secreto) sería preferible.

Por desgracia, también he dependido de enviar el secreto mediante un encabezado. Quizás @j.jaffeux pueda sugerir una alternativa mejor, que utilizaré de inmediato :sweat_smile:

Creé un paso de código para calcular una firma. Existe una Web Crypto API que puede calcular un HMAC-SHA256 mucho más fácilmente, pero utiliza una promesa y eso no funciona dentro de un eval. Así que le pedí a una IA que creara un método nativo de JavaScript para calcular HMAC-SHA256, añadí una variable con la clave secret y el secreto como su valor (, y terminé con un script de Código como este:

// Variables disponibles:
//   $input.item.json       - datos JSON del elemento actual
//   $input.all()           - matriz de todos los elementos de entrada
//   $json                  - atajo para $input.item.json
//   $("NodeName").item     - acceder al elemento emparejado desde otro nodo
//   $vars.KEY              - variables del flujo de trabajo
//   $site_settings.NAME    - configuraciones del sitio
//   $execution             - metadatos de ejecución (id, workflow_name, ...)
//   $current_user          - usuario que ejecuta el flujo de trabajo
//   $helpers.absoluteUrl(path) - convertir una ruta relativa en una URL absoluta
//   console.log/warn/error - registro

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

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

      // Manejar pares de sustitutos
      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;

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

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

    // Añadir longitud del mensaje de 64 bits en big-endian.
    // Los enteros de JS son seguros aquí para tamaños de carga de trabajo normales.
    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);

  // Las claves más largas que el tamaño de bloque de SHA-256 se hashean primero.
  if (keyBytes.length > blockSize) {
    keyBytes = sha256(keyBytes);
  }

  // Rellenar la clave hasta 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
};

A esto le sigue un paso de solicitud HTTP con una cabecera con la clave X-Discourse-Workflow-Signature y el valor {{ $("Code").item.json.signature }}, tipo de contenido JSON, y cuerpo de la solicitud {{ $("Code").item.json.payload }}.

El resultado es una solicitud 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-Signature: sha256=9ad2820b545013bdd18d4e7e6139624b91f576498e528201fa7759ec58dd598c

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

X-Discourse-Workflow-Secret es una firma HMAC-SHA256 que utiliza el valor del argumento secret… ¡justo como un webhook firmado! :slight_smile:

Puede que no sea bonito, pero parece que está funcionando.

Actualización: Prefijé la firma con sha256= para coincidir con el estilo de las firmas de webhook.

¡Parece bastante legítimo! ¡Gracias por compartirlo de vuelta!

Por cierto, probé este HMAC-SHA256 nativo contra la Web Crypto API para confirmar que producen los mismos resultados con mensajes y claves aleatorias, y las firmas coincidieron. No puedo garantizar que no haya casos especiales en los que difieran, pero estoy lo suficientemente satisfecho para usarlo en producción. :slightly_smiling_face: