# Новостной бот для канала Falcon Black: Python 3.9+, только requests. # Сам публикует новости в канал по таймеру (с кнопкой «Читать»), правит свои # записи, когда новость в ленте поменялась, и удаляет старые. # # 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' python3 news.py # Номер канала программа узнаёт сама — из события my_chat_member, когда бота # добавляют. Убрали бота из канала — программа забывает канал. # Новый бот первые 3 дня не шлёт ссылок в группы и каналы (403 links_new_bot, # кнопка «Читать» — тоже ссылка): программа ждёт и публикует, когда срок выйдет. # # Переменные — как у news.mjs: FB_BOT_TOKEN, NEWS_FEED (RSS или Atom; без неё — # пример из трёх новостей), NEWS_EVERY_MIN (15), NEWS_KEEP_DAYS (0 — не удалять), # NEWS_STATE (news-state.json), NEWS_ONCE=1 — один проход и выход. import html, json, os, re, sys, time import xml.etree.ElementTree as ET from datetime import datetime, timezone import requests API = os.environ.get("FB_API", "https://falconblack.org/bot-api/v1").rstrip("/") TOKEN = os.environ.get("FB_BOT_TOKEN", "").strip() FEED = os.environ.get("NEWS_FEED", "") EVERY_S = max(1, float(os.environ.get("NEWS_EVERY_MIN") or 15)) * 60 KEEP_DAYS = max(0, float(os.environ.get("NEWS_KEEP_DAYS") or 0)) STATE_FILE = os.environ.get("NEWS_STATE", "news-state.json") ONCE = os.environ.get("NEWS_ONCE") == "1" # В канал — не больше 20 записей в минуту от бота (предел сервера): за проход — немного. POSTS_PER_PASS = 5 if not re.fullmatch(r"\d{10,16}:fb1_[0-9A-Za-z]{49}", TOKEN): sys.exit("Нет ключа или он не той формы. Положите ключ бота в переменную FB_BOT_TOKEN.") s = requests.Session() s.headers["Authorization"] = "Bearer " + TOKEN # ключ — только в заголовке def log(*a): print(datetime.now(timezone.utc).isoformat(timespec="seconds"), *a, flush=True) class ApiError(Exception): def __init__(self, j): super().__init__(j.get("description")) self.code, self.key = j.get("error_code"), j.get("error_key") def call(method, **params): for attempt in range(1, 6): try: j = s.post(f"{API}/{method}", json=params, timeout=65).json() except (requests.RequestException, ValueError): # сбой сети или ответ не JSON — повтор if attempt == 5: raise ApiError({"description": f"{method}: сеть недоступна"}) time.sleep(2 * attempt) continue if j.get("ok"): return j["result"] if j.get("error_code") == 429 and attempt < 5: # предел: ждём, сколько сказали time.sleep((j.get("parameters") or {}).get("retry_after", 3)) continue raise ApiError(j) # ── Что программа помнит ─────────────────────────────────────────────────── # channels: номер канала → {title, canPost}; posts: "<канал> <новость>" → {chatId, messageId, title, at}. def load_state(): try: with open(STATE_FILE, encoding="utf-8") as f: return json.load(f) except (OSError, ValueError): return {"offset": 0, "channels": {}, "posts": {}} state = load_state() def save_state(): with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def forget_channel(chat_id, why): chat_id = str(chat_id) if chat_id not in state["channels"]: return log(f"канал {chat_id} забыт: {why}") del state["channels"][chat_id] state["posts"] = {k: p for k, p in state["posts"].items() if str(p["chatId"]) != chat_id} save_state() # ── Лента ────────────────────────────────────────────────────────────────── def parse_feed(xml): """RSS () и Atom (): [{id, title, link}], сверху новые.""" root = ET.fromstring(xml) items = [] for el in root.iter(): name = el.tag.rsplit("}", 1)[-1] if name not in ("item", "entry"): continue fields = {c.tag.rsplit("}", 1)[-1]: c for c in el} title = (fields["title"].text or "").strip() if "title" in fields else "" # Заголовок в CDATA часто сам с разметкой и & — людям нужен простой текст. title = html.unescape(re.sub(r"<[^>]+>", "", title)).strip() link = "" for c in el: # RSS: адрес; Atom: if c.tag.rsplit("}", 1)[-1] == "link" and c.get("rel", "alternate") == "alternate": link = (c.text or c.get("href") or "").strip() break guid = fields.get("guid") if "guid" in fields else fields.get("id") item_id = (guid.text or "").strip() if guid is not None else (link or title) if title and link.startswith("https://"): items.append({"id": item_id or link, "title": title[:1000], "link": link}) return items def demo_feed(): """Без NEWS_FEED — пример: три новости, у одной заголовок меняется (так видно правку записи).""" now = datetime.now(timezone.utc).strftime("%H:%M") return [ {"id": "demo-clock", "title": f"Новостной бот работает · проверено в {now} UTC", "link": "https://falconblack.org/bots"}, {"id": "demo-docs", "title": "Как написать своего бота", "link": "https://falconblack.org/bots"}, {"id": "demo-hello", "title": "Здравствуйте! Здесь будут новости", "link": "https://falconblack.org/"}, ] def read_feed(): if not FEED: return demo_feed() r = requests.get(FEED, headers={"User-Agent": "falcon-news-bot/1"}, timeout=20) r.raise_for_status() return parse_feed(r.content) # ── Публикация, правка, удаление ─────────────────────────────────────────── def button(link): return {"inline_keyboard": [[{"text": "Читать", "url": link}]]} def publish(items): for chat_id, ch in list(state["channels"].items()): if not ch.get("canPost"): continue posted = 0 for item in reversed(items): # снизу вверх: новые — ниже в канале key = f"{chat_id} {item['id']}" known = state["posts"].get(key) try: if not known: if posted >= POSTS_PER_PASS: continue # chat_id канала — отрицательное число: номер этого канала у вашего бота. m = call("sendMessage", chat_id=int(chat_id), text=item["title"], reply_markup=button(item["link"])) state["posts"][key] = {"chatId": chat_id, "messageId": m["message_id"], "title": item["title"], "at": time.time()} posted += 1 log(f"канал {chat_id}: опубликовано «{item['title']}»") elif known["title"] != item["title"]: # Свою запись бот правит всегда; чужие — только с правом «Изменять чужие записи». call("editMessageText", chat_id=int(chat_id), message_id=known["messageId"], text=item["title"], reply_markup=button(item["link"])) known["title"] = item["title"] log(f"канал {chat_id}: исправлено «{item['title']}»") save_state() except ApiError as e: # Новый бот первые 3 дня не шлёт ссылок в группы и каналы (и кнопок-ссылок тоже): # канал не забываем — записи пойдут сами, когда срок выйдет. if e.key == "links_new_bot": log(f"канал {chat_id}: {e} — запись подождёт") break # 403 — бота убрали из канала или сняли право публиковать; 400 — записи уже нет. if e.code == 403: forget_channel(chat_id, str(e)) break if e.code == 400 and known: state["posts"].pop(key, None) save_state() continue log(f"канал {chat_id}: {e.code} {e}") def cleanup(): if not KEEP_DAYS: return before = time.time() - KEEP_DAYS * 86400 for key, p in list(state["posts"].items()): if p["at"] >= before: continue try: # Свои записи канала бот удаляет в любое время (в группах — только 48 часов). call("deleteMessage", chat_id=int(p["chatId"]), message_id=p["messageId"]) log(f"канал {p['chatId']}: удалена старая запись «{p['title']}»") except ApiError as e: if e.code == 403 and e.key != "links_new_bot": forget_channel(p["chatId"], str(e)) continue if e.code != 400: log(f"удаление: {e.code} {e}") continue state["posts"].pop(key, None) save_state() def run_pass(): try: publish(read_feed()) cleanup() except (ApiError, requests.RequestException, ET.ParseError) as e: log(f"проход не удался: {e}") # ── События: в какие каналы бота добавили ────────────────────────────────── me = call("getMe") def handle(u): cm = u.get("my_chat_member") if cm: chat, now = cm["chat"], cm["new_chat_member"] if chat["type"] == "channel": if now["status"] == "administrator": can_post = now.get("can_post_messages") is True state["channels"][str(chat["id"])] = {"title": chat.get("title", ""), "canPost": can_post} save_state() log(f"канал «{chat.get('title', '')}» ({chat['id']}): {'можно публиковать' if can_post else 'права публиковать нет'}") if can_post: run_pass() else: forget_channel(chat["id"], f"статус {now['status']}") elif chat["type"] == "group" and now["status"] in ("member", "administrator"): # Этот бот — для каналов: из группы уходит сам. try: call("leaveChat", chat_id=chat["id"]) except ApiError as e: log(f"leaveChat: {e}") return # Человек написал боту лично — подскажем, как добавить его в канал. msg = u.get("message") or {} if (msg.get("chat") or {}).get("type") == "private": link = f"https://falconblack.org/bot/{me['username']}?startchannel&admin=post_messages+delete_messages" try: call("sendMessage", chat_id=msg["chat"]["id"], text="Я публикую новости в каналах. Добавьте меня в свой канал — нажмите кнопку и выберите канал.", reply_markup={"inline_keyboard": [[{"text": "Добавить в канал", "url": link}]]}) except ApiError as e: if e.key != "links_new_bot": raise # Новый бот первые 3 дня шлёт ссылки только своему автору — подскажем словами. call("sendMessage", chat_id=msg["chat"]["id"], text="Я публикую новости в каналах. Добавьте меня в свой канал: мой профиль → «Добавить в группу или канал» → «Каналы».") def take_updates(timeout): updates = call("getUpdates", offset=state["offset"], timeout=timeout, allowed_updates=["message", "my_chat_member"]) for u in updates: state["offset"] = u["update_id"] + 1 try: handle(u) except (ApiError, requests.RequestException) as e: log(f"событие {u['update_id']}: {e}") save_state() log(f"@{me['username']}: каналов в памяти — {len(state['channels'])}") if ONCE: take_updates(0) run_pass() sys.exit(0) next_pass = 0.0 while True: if time.time() >= next_pass: run_pass() next_pass = time.time() + EVERY_S try: # Опрос не дольше, чем до следующего прохода по ленте. take_updates(int(max(1, min(50, next_pass - time.time())))) except ApiError as e: if e.code in (401, 409): # ключ или вторая копия программы — повтор не поможет sys.exit(f"{e.code} {e.key}: {e}") time.sleep(3)