#!/usr/bin/env node // ───────────────────────────────────────────────────────────────────────────── // Новостной бот для канала Falcon Black: Node.js 20+, без библиотек. // Сам публикует новости в канал по таймеру (с кнопкой «Читать»), правит свои // записи, когда новость в ленте поменялась, и удаляет старые. // // 1. Создайте бота в @botnest (/newbot) и возьмите ключ (/token). // 2. Добавьте бота в свой канал: профиль бота → «Добавить в группу или канал» → // «Каналы», или ссылкой // https://falconblack.org/bot/<ник>?startchannel&admin=post_messages+delete_messages // В канале бот всегда начальник; «Публиковать записи» включено по умолчанию. // 3. Запустите программу: // FB_BOT_TOKEN='…' NEWS_FEED='https://example.org/rss.xml' node news.mjs // Номер канала программа узнаёт сама — из события my_chat_member, когда бота // добавляют. Убрали бота из канала — программа забывает канал. // Новый бот первые 3 дня не шлёт ссылок в группы и каналы (403 links_new_bot, // кнопка «Читать» — тоже ссылка): программа ждёт и публикует, когда срок выйдет. // // Переменные: // FB_BOT_TOKEN — ключ бота (обязательно; только в заголовке Authorization) // NEWS_FEED — адрес ленты RSS или Atom; без него — пример из трёх новостей // NEWS_EVERY_MIN — как часто смотреть ленту, в минутах (по умолчанию 15) // NEWS_KEEP_DAYS — через сколько дней удалять свои записи (0 — не удалять) // NEWS_STATE — файл, где программа помнит каналы и записи (news-state.json) // NEWS_ONCE=1 — один проход и выход (для cron и для проверки) // ───────────────────────────────────────────────────────────────────────────── import fs from "node:fs"; const API = (process.env.FB_API || "https://falconblack.org/bot-api/v1").replace(/\/+$/, ""); const TOKEN = (process.env.FB_BOT_TOKEN || "").trim(); const FEED = process.env.NEWS_FEED || ""; const EVERY_MS = Math.max(1, Number(process.env.NEWS_EVERY_MIN) || 15) * 60_000; const KEEP_DAYS = Math.max(0, Number(process.env.NEWS_KEEP_DAYS) || 0); const STATE_FILE = process.env.NEWS_STATE || "news-state.json"; const ONCE = process.env.NEWS_ONCE === "1"; // В канал — не больше 20 записей в минуту от бота (предел сервера); за один // проход публикуем немного, остальное — в следующий. const POSTS_PER_PASS = 5; if (!/^\d{10,16}:fb1_[0-9A-Za-z]{49}$/.test(TOKEN)) { console.error("Нет ключа или он не той формы. Положите ключ бота в переменную FB_BOT_TOKEN."); process.exit(2); } const log = (...a) => console.log(new Date().toISOString(), ...a); const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms)); // ── Вызов Bot API ──────────────────────────────────────────────────────────── async function call(method, params = {}) { for (let attempt = 1; ; attempt++) { let r, j; try { r = await fetch(`${API}/${method}`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify(params), }); j = await r.json(); } catch { if (attempt >= 5) throw new Error(`${method}: сеть недоступна`); await sleep(2000 * attempt); // сбой сети или ответ не JSON — повтор continue; } if (j.ok) return j.result; // 429 — предел: ждём, сколько сказали, и повторяем тот же вызов. if (j.error_code === 429 && attempt < 5) { await sleep((j.parameters?.retry_after ?? 3) * 1000); continue; } throw Object.assign(new Error(j.description), { code: j.error_code, key: j.error_key }); } } // ── Что программа помнит ───────────────────────────────────────────────────── // channels: номер канала → {title, canPost}; posts: "<канал> <новость>" → {chatId, messageId, title, at}. function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); } catch { return { offset: 0, channels: {}, posts: {} }; } } const state = loadState(); const saveState = () => fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); function forgetChannel(chatId, why) { if (!state.channels[chatId]) return; log(`канал ${chatId} забыт: ${why}`); delete state.channels[chatId]; for (const [key, p] of Object.entries(state.posts)) if (String(p.chatId) === String(chatId)) delete state.posts[key]; saveState(); } // ── Лента ──────────────────────────────────────────────────────────────────── function decode(s) { return s.replace(//g, "$1").replace(/<[^>]+>/g, "") .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/g, "&").trim(); } function tag(block, name) { const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)`, "i").exec(block); return m ? decode(m[1]) : ""; } /** RSS () и Atom (): [{id, title, link}], сверху новые. */ function parseFeed(xml) { const items = []; for (const m of xml.matchAll(/<(item|entry)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi)) { const b = m[2]; // RSS: адрес; Atom: — в атрибуте «&» записано как «&». const link = tag(b, "link") || decode(/]*href="([^"]+)"/i.exec(b)?.[1] ?? ""); const title = tag(b, "title"); const id = tag(b, "guid") || tag(b, "id") || link || title; if (title && /^https:\/\//.test(link)) items.push({ id, title: title.slice(0, 1000), link }); } return items; } /** Без NEWS_FEED — пример: три новости, у одной заголовок меняется (так видно правку записи). */ function demoFeed() { const time = new Date().toISOString().slice(11, 16); return [ { id: "demo-clock", title: `Новостной бот работает · проверено в ${time} UTC`, link: "https://falconblack.org/bots" }, { id: "demo-docs", title: "Как написать своего бота", link: "https://falconblack.org/bots" }, { id: "demo-hello", title: "Здравствуйте! Здесь будут новости", link: "https://falconblack.org/" }, ]; } async function readFeed() { if (!FEED) return demoFeed(); const r = await fetch(FEED, { headers: { "User-Agent": "falcon-news-bot/1" }, signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error(`лента ответила ${r.status}`); return parseFeed(await r.text()); } // ── Публикация, правка, удаление ───────────────────────────────────────────── const button = (link) => ({ inline_keyboard: [[{ text: "Читать", url: link }]] }); async function publish(items) { for (const [chatId, ch] of Object.entries(state.channels)) { if (!ch.canPost) continue; let posted = 0; // Снизу вверх: старые новости — раньше, новые — ниже в канале. for (const item of [...items].reverse()) { const key = `${chatId} ${item.id}`; const known = state.posts[key]; try { if (!known) { if (posted >= POSTS_PER_PASS) continue; // chat_id канала — отрицательное число: номер этого канала у вашего бота. const m = await call("sendMessage", { chat_id: Number(chatId), text: item.title, reply_markup: button(item.link) }); state.posts[key] = { chatId, messageId: m.message_id, title: item.title, at: Date.now() }; posted++; log(`канал ${chatId}: опубликовано «${item.title}»`); } else if (known.title !== item.title) { // Свою запись бот правит всегда; чужие — только с правом «Изменять чужие записи». await call("editMessageText", { chat_id: Number(chatId), message_id: known.messageId, text: item.title, reply_markup: button(item.link) }); known.title = item.title; log(`канал ${chatId}: исправлено «${item.title}»`); } saveState(); } catch (e) { // Новый бот первые 3 дня не шлёт ссылок в группы и каналы (и кнопок-ссылок тоже): // канал не забываем — записи пойдут сами, когда срок выйдет. if (e.key === "links_new_bot") { log(`канал ${chatId}: ${e.message} — запись подождёт`); break; } // 403 — бота убрали из канала или сняли право публиковать; 400 — записи уже нет. if (e.code === 403) { forgetChannel(chatId, e.message); break; } if (e.code === 400 && known) { delete state.posts[key]; saveState(); continue; } log(`канал ${chatId}: ${e.code ?? ""} ${e.message}`); } } } } async function cleanup() { if (!KEEP_DAYS) return; const before = Date.now() - KEEP_DAYS * 86_400_000; for (const [key, p] of Object.entries(state.posts)) { if (p.at >= before) continue; try { // Свои записи канала бот удаляет в любое время (в группах — только 48 часов). await call("deleteMessage", { chat_id: Number(p.chatId), message_id: p.messageId }); log(`канал ${p.chatId}: удалена старая запись «${p.title}»`); } catch (e) { if (e.code === 403 && e.key !== "links_new_bot") { forgetChannel(p.chatId, e.message); continue; } if (e.code !== 400) { log(`удаление: ${e.code ?? ""} ${e.message}`); continue; } } delete state.posts[key]; saveState(); } } // Проход по таймеру и проход «бота только что добавили» не идут разом — иначе одна новость вышла бы дважды. let running = null; function pass() { running ??= (async () => { try { await publish(await readFeed()); await cleanup(); } catch (e) { log(`проход не удался: ${e.message}`); } })().finally(() => { running = null; }); return running; } // ── События: в какие каналы бота добавили ──────────────────────────────────── let me = null; async function handle(u) { const cm = u.my_chat_member; if (cm) { const chat = cm.chat; const now = cm.new_chat_member; if (chat.type === "channel") { if (now.status === "administrator") { state.channels[chat.id] = { title: chat.title, canPost: now.can_post_messages === true }; saveState(); log(`канал «${chat.title}» (${chat.id}): ${now.can_post_messages ? "можно публиковать" : "права публиковать нет"}`); if (now.can_post_messages) await pass(); } else { forgetChannel(chat.id, `статус ${now.status}`); } } else if (chat.type === "group" && (now.status === "member" || now.status === "administrator")) { // Этот бот — для каналов: из группы уходит сам. await call("leaveChat", { chat_id: chat.id }).catch((e) => log(`leaveChat: ${e.message}`)); } return; } // Человек написал боту лично — подскажем, как добавить его в канал. const m = u.message; if (m?.chat?.type === "private") { const link = `https://falconblack.org/bot/${me.username}?startchannel&admin=post_messages+delete_messages`; try { await call("sendMessage", { chat_id: m.chat.id, text: "Я публикую новости в каналах. Добавьте меня в свой канал — нажмите кнопку и выберите канал.", reply_markup: { inline_keyboard: [[{ text: "Добавить в канал", url: link }]] }, }); } catch (e) { if (e.key !== "links_new_bot") throw e; // Новый бот первые 3 дня шлёт ссылки только своему автору — подскажем словами. await call("sendMessage", { chat_id: m.chat.id, text: "Я публикую новости в каналах. Добавьте меня в свой канал: мой профиль → «Добавить в группу или канал» → «Каналы».", }); } } } me = await call("getMe"); log(`@${me.username}: каналов в памяти — ${Object.keys(state.channels).length}`); if (ONCE) { // Один проход: забрать накопившиеся события и опубликовать. const updates = await call("getUpdates", { offset: state.offset, timeout: 0, allowed_updates: ["message", "my_chat_member"] }); for (const u of updates) { state.offset = u.update_id + 1; await handle(u).catch((e) => log(`событие: ${e.message}`)); } saveState(); await pass(); process.exit(0); } setInterval(pass, EVERY_MS); await pass(); for (;;) { let updates; try { updates = await call("getUpdates", { offset: state.offset, timeout: 50, allowed_updates: ["message", "my_chat_member"] }); } catch (e) { // 401 — ключ, 409 — запущена вторая копия программы: повтор не поможет. if ([401, 409].includes(e.code)) { console.error(`${e.code} ${e.key}: ${e.message}`); process.exit(1); } await sleep(3000); continue; } for (const u of updates) { state.offset = u.update_id + 1; await handle(u).catch((e) => log(`событие ${u.update_id}: ${e.code ?? ""} ${e.message}`)); } saveState(); }