Atualizar automaticamente uma publicação do Discourse com base em um feed do X

Acabei de configurar o seguinte no OpenAI

Como o Tibo posta no X, o Discourse agora atualiza automaticamente um post que acompanha os tweets.

Como isso funciona?

Uma tabela de dados como cache

O Discourse Workflows vem com um recurso de tabela de dados.

Isso nos permite armazenar informações estruturadas entre as execuções do workflow.

Uma peça crítica ao integrar com o X é evitar o estouro de custos da API. Para isso, armazenamos um cache de todas as informações raspadas para evitar reconsultar tweets antigos.

Uma tabela de dados é perfeita para isso.

Um nó de configuração

“Definir campos” é perfeito para nós de configuração. Neste caso, precisei de alguma configuração “por workflow” que é usada ao longo do workflow, como o ID do tópico para postar, hora de início da campanha, nomes de usuário e assim por diante.

Isso ajuda a manter o workflow mais fácil de raciocinar.

Requisições HTTP para o X

O workflow depende de 2 endpoints do X:

Um muito simples para buscar o ID do X do Tibo.

Um segundo para buscar a linha do tempo:

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

Isso também começa a expor alguns detalhes de implementação :slight_smile: Construí o workflow usando um agente e executei uma “API falsa do X” durante o processo para economizar custos (daí o localhost ali)

Usando blocos “Se” de fluxo, podemos continuar iterando pela linha do tempo de um usuário, enquanto houver mais páginas. Um bom detalhe de implementação da API do X é que você só paga pelos posts reais que recebe, então consultas com zero resultados não custam nada.

A análise dos tweets é feita usando este pequeno nó 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 };

Adicionando inteligência

O ingrediente-chave para sincronizar conteúdo com sucesso é a inteligência:



Passamos uma lista de tweets como entrada → obtemos um documento JSON corretamente extraído. Esse tipo de processo requer inteligência.

Ele formata o conteúdo E encontra as peças de informação mais relevantes.

Atualizando o post no Discourse

Usamos um nó de script para renderizar a tabela por:

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

O pequeno truque é que o post no Discourse tem um marcador especial:


Dessa forma, depois de gerar a tabela, podemos verificar se algo mudou antes de atualizar a tabela.

Se a tabela mudar, atualizamos a Tabela de Dados de rastreamento:

Como eu construí isso?

Workflows são recursos incrivelmente poderosos; construir grandes workflows como este manualmente levaria muito tempo.

Em vez de fazer isso, recortei a um agente. Uso term-llm.com para todo o meu desenvolvimento, ele se integra muito limpamente ao dv. Claro, seu agente de escolha, mas um recurso-chave aqui é ter um ambiente Discourse funcional.

Meus passos foram:

  1. dv new workflow-build - para criar um novo ambiente limpo.

    1. Uso um hook do dv especial que inicia um serviço web term-llm serve que se conecta de volta ao meu hub, isso me dá um “agente rico” instantâneo em um ambiente descartável onde posso soltar a imaginação.
  2. Eu dei o prompt:


Usei GPT 6.1 Sol para a construção e já tinha algo funcionando após uma única tentativa:

  1. Eu refinei
  • A primeira tentativa não usava uma tabela de dados - dei o prompt para mover o armazenamento para lá

  • Em seguida, iterei para mover toda a configuração para um nó central para que fosse mais fácil gerenciá-la

  1. Exportei o workflow - e depois importei para produção

  2. Iterei um pouco com o agente porque na produção havia alguns casos de borda faltando (nosso xml de exportação não inclui definições de tabela de dados e definições de agente)

E se você precisar construir algo assim?

Este post será incrivelmente útil para agentes, um prompt simples ao longo das linhas de:

Sincronizar discussões importantes sobre “X” nas Contas A,B,C para meu tópico seguindo método semelhante em LINK PARA META

Permitirá que você faça sincronização de dados arbitrária do X.

Social → Discourse é um vetor muito importante para a comunidade, workflows permitem que você mantenha um registro de longa duração que está aberto a discussões mais nuances sem forçar as pessoas existentes na sua empresa a centralizar todo o post de conteúdo no Discourse.

Workflows são o bloco de construção que você pode usar para esse tipo de sincronização e as opções aqui são bastante amplas.

  • Postar tópicos sombra quando pessoas específicas postam conteúdo nas redes sociais
  • Manter um tópico de longa duração atualizado com base na atividade nas redes sociais
  • … e muito mais
1 curtida