Mettre à jour automatiquement un message Discourse à partir d'un flux X

Je viens de mettre en place la configuration suivante sur OpenAI

Comme le poste Tibo sur X, Discourse met désormais à jour automatiquement un sujet qui suit les tweets.

Comment cela fonctionne-t-il ?

Un tableau de données comme cache

Discourse Workflows est livré avec une fonctionnalité de tableau de données.

Cela nous permet de stocker des informations structurées entre les exécutions du workflow.

Un élément crucial lors de l’intégration avec X est d’éviter le dépassement des coûts de l’API. Pour cela, nous stockons un cache de toutes les informations scrapées afin d’éviter de requêter à nouveau les anciens tweets.

Un tableau de données est parfait pour cela.

Un nœud de configuration

« Set fields » (Définir les champs) est idéal pour les nœuds de configuration. Dans ce cas, j’avais besoin d’une configuration « par workflow » utilisée tout au long du processus, comme l’identifiant du sujet à poster, l’heure de début de la campagne, les noms d’utilisateur, etc.

Cela aide à rendre le workflow plus facile à comprendre.

Requêtes HTTP vers X

Le workflow s’appuie sur 2 points de terminaison (endpoints) X :

Un très simple pour rechercher l’identifiant X de Tibo.

Un second pour rechercher la timeline :

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 || [] };

Cela commence aussi à révéler certains détails d’implémentation :slight_smile: J’ai construit le workflow en utilisant un agent et j’ai exécuté une « fausse API X » pendant le processus pour économiser sur les coûts (d’où la présence de localhost ici).

En utilisant les blocs « If » du flux, nous pouvons continuer à parcourir la timeline d’un utilisateur tant que nous avons plus de pages. Un détail d’implémentation intéressant de l’API X est que vous ne payez que pour les publications réelles que vous obtenez, donc les requêtes sans résultat ne coûtent rien.

L’analyse des tweets est effectuée à l’aide de ce petit nœud 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 };

Ajouter de l’intelligence

L’ingrédient clé pour synchroniser avec succès le contenu est l’intelligence :



Nous passons une liste de tweets en entrée → nous obtenons un document JSON correctement extrait. Ce type de processus nécessite de l’intelligence.

Il met en forme le contenu ET trouve les éléments d’information les plus pertinents.

Mise à jour du sujet Discourse

Nous utilisons un nœud de script pour rendre le tableau par :

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 };

Le petit truc, c’est que le sujet Discourse a un marqueur spécial :


De cette façon, après avoir généré le tableau, nous pouvons vérifier si quelque chose a changé avant de mettre à jour le tableau.

Si le tableau change, nous mettons à jour le tableau de données de suivi :

Comment j’ai construit cela ?

Les workflows sont des fonctionnalités incroyablement puissantes ; en construire de grands comme celui-ci à la main prendrait beaucoup de temps.

Au lieu de faire cela, j’ai fait confiance à un agent. J’utilise term-llm.com pour tout mon développement, il s’intègre très proprement dans dv. Bien sûr, votre agent de choix, mais une fonctionnalité clé ici est d’avoir un environnement Discourse fonctionnel.

Mes étapes étaient les suivantes :

  1. dv new workflow-build - pour créer un nouvel environnement propre.

    1. J’utilise un dv hook spécial qui démarre un service web term-llm serve qui se connecte à mon hub, ce qui me donne un « agent riche » instantané dans un environnement jetable où je peux m’amuser.
  2. Je l’ai invité (prompted) :


J’ai utilisé GPT 6.1 Sol pour la construction et j’avais déjà quelque chose de fonctionnel après un seul essai :

  1. Je l’ai affiné
  • La première tentative n’utilisait pas de tableau de données - j’ai demandé de déplacer le stockage là-bas

  • Ensuite, j’ai itéré pour déplacer toute la configuration dans un nœud central afin de la gérer plus facilement

  1. J’ai exporté le workflow - puis l’ai importé en production

  2. J’ai itéré un peu avec l’agent car en production, il y avait quelques cas limites manquants (notre export xml n’inclut pas les définitions des tableaux de données et des agents)

Et si vous devez construire quelque chose comme cela ?

Ce post sera incroyablement utile aux agents, un simple prompt dans le genre :

Synchroniser les discussions importantes sur « X » des comptes A, B, C dans mon sujet en suivant une méthode similaire dans LIEN VERS LE MÉTA

Vous permettra de faire une synchronisation de données arbitraire depuis X.

Social → Discourse est un vecteur très important pour la communauté, les workflows vous permettent de maintenir un enregistrement à long terme qui est ouvert à une discussion plus nuancée sans forcer les personnes existantes dans votre entreprise à centraliser toute la publication de contenu sur Discourse.

Les workflows sont le bloc de construction que vous pouvez utiliser pour ce type de synchronisation et les options ici sont assez larges.

  • Poster des sujets ombrés (shadow topics) lorsque des personnes spécifiques publient du contenu sur les réseaux sociaux
  • Maintenir un sujet à long terme à jour basé sur l’activité sur les réseaux sociaux
  • … et bien plus encore
1 « J'aime »