Actualización automática de una publicación de Discourse a partir de un feed de X

Acabo de configurar lo siguiente en OpenAI

Como señala Tibo en X, Discourse ahora actualizará automáticamente una publicación que lleva un registro de los tweets.

¿Cómo funciona?

Una tabla de datos como caché

Discourse Workflows incluye una función de tabla de datos.

Esto nos permite almacenar información estructurada entre ejecuciones del flujo de trabajo.

Una pieza crítica al integrar con X es evitar el exceso de costos de la API. Para lograrlo, almacenamos una copia en caché de toda la información que raspamos para evitar volver a consultar tweets antiguos.

Una tabla de datos es perfecta para esto.

Un nodo de configuración

«Set fields» (Establecer campos) es perfecto para nodos de configuración. En este caso, necesitaba cierta configuración «por flujo de trabajo» que se utiliza a lo largo del flujo, como el ID del tema en el que publicar, la hora de inicio de la campaña, nombres de usuario, etc.

Ayuda a mantener el flujo de trabajo más fácil de razonar.

Solicitudes HTTP a X

El flujo de trabajo se basa en 2 puntos finales de X:

Uno muy simple para buscar el ID de X de Tibo.

Un segundo para buscar la línea de tiempo:

const data = $json;
const checkpoint = $("Read checkpoint").first().json;
const userId = data.user_id || data.data?.id;
if (!userId || !/^[A-Za-z0-9-]+$/.test(userId)) {
  throw new Error("Unexpected X user lookup response");
}
const configuration = $("Configuration").first().json;
const apiBase = configuration.api_base_url.replace(/\/$/, "");
const demo = /^http:\/\/(?:localhost|127\.0\.0\.1):[1-9]\d*$/.test(apiBase);
const token = data.next_token || "";
const base = apiBase + "/2/users/" + userId + "/tweets";
const params = "max_results=" + (demo ? "5" : "100") + "&post.fields=id,text,created_at,entities,attachments,note_post&expansions=referenced_posts";
const boundary = checkpoint.cursor ? "since_id=" + encodeURIComponent(checkpoint.cursor) : "start_time=" + encodeURIComponent(configuration.campaign_start_time);
const url = base + "?" + params + "&" + boundary + (token ? "&pagination_token=" + encodeURIComponent(token) : "");
return { user_id: userId, url: url, tweets: data.tweets || [], candidate_cursor: data.candidate_cursor || checkpoint.cursor,
  page_count: data.page_count || 0, tokens: data.tokens || [] };

Esto también comienza a exponer algunos detalles de implementación :slight_smile: Construí el flujo de trabajo usando un agente y ejecuté una «API de X falsa» durante el proceso para ahorrar costos (de ahí el localhost allí).

Usando bloques «If» de flujo, podemos seguir iterando a través de la línea de tiempo de un usuario mientras haya más páginas. Un buen detalle de implementación de la API de X es que solo pagas por las publicaciones reales que obtienes, por lo que las consultas con cero resultados no cuestan nada.

El análisis de tweets se realiza usando este pequeño nodo de script:

const previous = $("Build page URL").item.json;
const response = $json;
if (!response || !response.meta || !Array.isArray(response.data || [])) {
  throw new Error("Incomplete X timeline response; refuse to update the post");
}
if (response.errors?.length) { throw new Error("X returned partial data/errors; refuse to update the post"); }
const next = response.meta.next_token || "";
if (next && (previous.tokens.includes(next) || previous.page_count >= 39)) {
  throw new Error("X pagination repeated or exceeded the 40-page safety limit");
}
const greater = (a, b) => a.length > b.length || (a.length === b.length && a > b);
let candidate = previous.candidate_cursor;
for (const tweet of response.data || []) {
  if (typeof tweet.id !== "string" || !/^\d+$/.test(tweet.id)) { throw new Error("X post ID must be a decimal string"); }
  if (!candidate || greater(tweet.id, candidate)) { candidate = tweet.id; }
}
const normalize = post => {
  const note = post.note_post || post.note_tweet;
  return { ...post, text: note?.text || post.text, entities: note?.entities || post.entities,
    referenced_tweets: post.referenced_posts || post.referenced_tweets || [] };
};
const expanded = Object.fromEntries((response.includes?.posts || response.includes?.tweets || [])
  .map(post => [post.id, normalize(post)]));
const page = (response.data || []).map(normalize).map(post => ({ ...post,
  referenced_detail_urls: post.referenced_tweets.flatMap(ref => {
    const quote = expanded[ref.id];
    return quote ? [quote.text || "", ...(quote.entities?.urls || []).map(url => url.unwound_url || url.expanded_url || url.url)] : [];
  }) }));
const tweets = previous.tweets.concat(page);
return { user_id: previous.user_id, tweets: tweets, candidate_cursor: candidate, next_token: next,
  has_more: !!next, has_tweets: tweets.length > 0, page_count: previous.page_count + 1,
  tokens: next ? previous.tokens.concat(next) : previous.tokens };

Añadiendo inteligencia

El ingrediente clave para sincronizar contenido con éxito es la inteligencia:



Pasamos una lista de tweets como entrada → obtenemos un documento JSON correctamente extraído. Este tipo de proceso requiere inteligencia.

Formatea el contenido Y encuentra las piezas de información más relevantes.

Actualizando la publicación de Discourse

Usamos un nodo de script para renderizar la tabla según:

const extracted = $("Poll result").first().json;
const checkpoint = $("Read checkpoint").first().json;
const apiBase = $("Configuration").first().json.api_base_url.replace(/\/$/, "");
const demo = /^http:\/\/(?:localhost|127\.0\.0\.1):[1-9]\d*$/.test(apiBase);
const raw = String($json.post?.raw || "");
const start = "<!-- x-ships-table:start -->";
const end = "<!-- x-ships-table:end -->";
const hasStart = raw.includes(start);
const hasEnd = raw.includes(end);
if (hasStart !== hasEnd ||
    (hasStart && (raw.indexOf(end) < raw.indexOf(start) ||
      raw.indexOf(start, raw.indexOf(start) + start.length) !== -1 ||
      raw.indexOf(end, raw.indexOf(end) + end.length) !== -1))) {
  throw new Error("Ambiguous workflow-owned table markers in designated post");
}
const greater = (a, b) => a.length > b.length || (a.length === b.length && a > b);
if (checkpoint.cursor && extracted.candidate_cursor && greater(checkpoint.cursor, extracted.candidate_cursor)) {
  throw new Error("Refusing to move the timeline cursor backwards");
}
const bySlot = {};
for (const ship of checkpoint.ships.concat(extracted.ships)) {
  if (!Number.isInteger(ship.day) || ship.day < 1 || ship.day > 28 ||
      !new RegExp("^" + ship.day + "(?:\\.[1-9][0-9]?)?$").test(ship.slot) ||
      typeof ship.source_id !== "string" || !/^\d+$/.test(ship.source_id) ||
      typeof ship.announcement !== "string" || !ship.announcement || ship.announcement.length > 400 ||
      (ship.details_url && !/^https?:\/\/[^\s<>()[\]|\\]+$/.test(ship.details_url))) {
    throw new Error("Invalid stored ship slot");
  }
  if (!bySlot[ship.slot] || greater(ship.source_id, bySlot[ship.slot].source_id)) { bySlot[ship.slot] = ship; }
}
const escapeCell = value => String(value).replace(/[|\r\n]/g, " ").replace(/\\/g, "\\\\").replace(/([\[\]_*`])/g, "\\$1");
const rows = [];
for (let day = 1; day <= 28; day++) {
  const ships = Object.values(bySlot).filter(ship => ship.day === day)
    .sort((a, b) => a.slot.localeCompare(b.slot, undefined, { numeric: true }));
  if (!ships.length) { rows.push("| " + day + " | Pending | — | — | — |"); }
  else for (const ship of ships) {
    const provenance = demo
      ? "[Demo post](" + apiBase + "/demo/posts/" + encodeURIComponent(ship.source_id) + ") (synthetic)"
      : "[X post](https://x.com/" + encodeURIComponent($("Configuration").first().json.username) + "/status/" + encodeURIComponent(ship.source_id) + ")";
    const details = ship.details_url ? "[Details](" + ship.details_url + ")" : "—";
    rows.push("| " + day + " | " + escapeCell(ship.slot) + " | " + escapeCell(ship.announcement) + " | " + details + " | " + provenance + " |");
  }
}
const ships = Object.values(bySlot).sort((a, b) => a.day - b.day || a.slot.localeCompare(b.slot, undefined, { numeric: true }));
const updated = start + "\n| Day | Ship | Announcement | Details | Source |\n| --- | --- | --- | --- | --- |\n" + rows.join("\n") + "\n" + end;
const replacement = hasStart
  ? raw.slice(0, raw.indexOf(start)) + updated + raw.slice(raw.indexOf(end) + end.length)
  : raw + (raw ? (raw.endsWith("\n") ? "\n" : "\n\n") : "") + updated;
return { changed: replacement !== raw, raw: replacement, ships_json: JSON.stringify(ships),
  since_id: extracted.candidate_cursor, user_id: extracted.user_id };

El pequeño truco es que la publicación de Discourse tiene un marcador especial:


De esa manera, después de generar la tabla, podemos verificar si algo cambió antes de actualizar la tabla.

Si la tabla cambia, actualizamos la tabla de datos de seguimiento:

¿Cómo construí esto?

Los flujos de trabajo son funciones increíblemente poderosas; construir uno gigante como este a mano tomaría mucho tiempo.

En lugar de hacer eso, me apoyé en un agente. Uso term-llm.com para todo mi desarrollo, se integra muy limpiamente con dv. Por supuesto, puedes usar el agente de tu elección, pero una función clave aquí es tener un entorno de Discourse funcional.

Mis pasos fueron:

  1. dv new workflow-build - para crear un nuevo entorno limpio.

    1. Uso un hook de dv especial que inicia un servicio web term-llm serve que se conecta de vuelta a mi hub, esto me da un «agente enriquecido» instantáneo en un entorno desechable donde puedo hacer lo que quiera.
  2. Le di el siguiente prompt:


Usé GPT 6.1 Sol para la construcción y ya tenía algo funcionando después de un solo intento:

  1. Lo refiné
  • El primer intento no usaba una tabla de datos - le di un prompt para mover el almacenamiento allí

  • Luego iteré para mover toda la configuración a un nodo central para que fuera más fácil de gestionar

  1. Exporté el flujo de trabajo - y luego lo importé en producción

  2. Iteré un poco con el agente porque en producción había algunos casos límite faltantes (nuestra exportación XML no incluye definiciones de tablas de datos y definiciones de agentes)

¿Qué pasa si necesitas construir algo como esto?

Esta publicación será increíblemente útil para los agentes, un prompt simple a lo largo de:

Sincronizar discusiones importantes sobre «X» en las cuentas A, B, C en mi tema siguiendo un método similar en ENLACE A META

Te permitirá hacer una sincronización de datos arbitraria desde X.

Social → Discourse es un vector muy importante para la comunidad; los flujos de trabajo te permiten mantener un registro de larga duración que está abierto a una discusión más matizada sin obligar a las personas existentes en tu empresa a centralizar toda la publicación de contenido en Discourse.

Los flujos de trabajo son el bloque de construcción que puedes usar para este tipo de sincronización y las opciones aquí son bastante amplias.

  • Publicar temas sombra cuando personas específicas publiquen contenido en redes sociales
  • Mantener un tema de larga duración actualizado basado en la actividad en redes sociales
  • … y mucho más
1 me gusta