Я только что настроил следующее в OpenAI
Как отмечает Тибо в X, теперь Discourse автоматически обновляет пост, который отслеживает твиты.
Как это работает?
Таблица данных в качестве кэша
Discourse Workflows поставляется с функцией таблиц данных.
Это позволяет хранить структурированную информацию между запусками рабочего процесса.
Ключевой момент при интеграции с X — предотвращение превышения расходов на API. Для этого мы храним кэш всей собранной информации, чтобы не запрашивать старые твиты повторно.
Таблица данных идеально подходит для этой задачи.
Узел конфигурации
«Set fields» (Установка полей) идеально подходит для узлов конфигурации. В данном случае мне потребовалась некоторая «конфигурация на уровне рабочего процесса», которая используется на протяжении всего процесса: ID темы для публикации, время начала кампании, имена пользователей и так далее.
Это помогает сделать рабочий процесс более понятным.
HTTP-запросы к X
Рабочий процесс опирается на 2 эндпоинта X:
Очень простой запрос для получения ID Тибо в X.
Второй запрос для получения ленты:
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 || [] };
Здесь также раскрываются некоторые детали реализации
Я строил рабочий процесс с помощью агента и в процессе запускал «фейковый API X», чтобы сэкономить на затратах (отсюда и localhost).
Используя блоки «If» (Если) в потоке, мы можем продолжать итерироваться по ленте пользователя, пока есть дополнительные страницы. Приятной особенностью API X является то, что вы платите только за фактически полученные посты, поэтому запросы с нулевым результатом ничего не стоят.
Разбор твитов выполняется с помощью этого небольшого скриптового узла:
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 };
Добавление интеллекта
Ключевой ингредиент для успешной синхронизации контента — это интеллект:
Мы передаем список твитов на вход → и получаем правильно извлеченный JSON-документ. Такой процесс требует интеллекта.
Он не только форматирует контент, но и находит наиболее релевантные фрагменты информации.
Обновление поста в Discourse
Мы используем скриптовый узел для рендеринга таблицы по принципу:
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 };
Небольшая хитрость заключается в том, что в посте Discourse есть специальный маркер:
Таким образом, после генерации таблицы мы можем проверить, изменилось ли что-либо, прежде чем обновлять таблицу.
Если таблица меняется, мы обновляем отслеживающую таблицу данных:
Как я это построил?
Рабочие процессы — невероятно мощная функция; ручная сборка таких огромных процессов, как этот, заняла бы много времени.
Вместо этого я опирался на агента. Я использую term-llm.com для всей своей разработки, он очень чисто интегрируется с dv. Конечно, можно использовать любого агента по вашему выбору, но ключевой особенностью здесь является наличие рабочей среды Discourse.
Мои шаги были следующими:
-
dv new workflow-build— для создания новой чистой среды.- Я использую специальный dv hook, который запускает веб-сервис term-llm serve, связанный с моим хабом. Это дает мне мгновенный «богатый агент» в одноразовой среде, где я могу делать что угодно.
-
Я сформулировал запрос:
Я использовал GPT 6.1 Sol для сборки, и уже после одного запуска получил работающий результат:
- Я доработал его
-
Первая попытка не использовала таблицу данных — я попросил перенести хранение туда.
-
Затем я итерировал, чтобы перенести всю конфигурацию в центральный узел, чтобы ее было проще управлять.
-
Я экспортировал рабочий процесс — и затем импортировал его в продакшен.
-
Я немного поиграл с агентом, потому что в продакшене отсутствовало несколько граничных случаев (наш экспорт XML не включает определения таблиц данных и определений агентов).
Что делать, если вам нужно построить что-то подобное?
Этот пост будет невероятно полезен агентам. Простой запрос в духе:
Синхронизируйте важные обсуждения об «X» в аккаунтах A, B, C в мою тему, используя аналогичный метод по ССЫЛКЕ НА МЕТА
позволит вам выполнять произвольную синхронизацию данных из X.
Соцсети → Discourse — это очень важный вектор для сообщества. Рабочие процессы позволяют вести постоянную запись, открытую для более нюансированного обсуждения, не заставляя существующих сотрудников вашей компании централизовать все публикации контента в Discourse.
Рабочие процессы — это строительный блок, который вы можете использовать для такого типа синхронизации, и варианты здесь довольно широки.
- Создавать «теневые» темы, когда определенные люди публикуют контент в соцсетях.
- Держать постоянную тему в актуальном состоянии на основе активности в соцсетях.
- … и многое другое.
















