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
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:
-
dv new workflow-build- para criar um novo ambiente limpo.- 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.
-
Eu dei o prompt:
Usei GPT 6.1 Sol para a construção e já tinha algo funcionando após uma única tentativa:
- 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
-
Exportei o workflow - e depois importei para produção
-
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
















