216 lines
8.2 KiB
Python
216 lines
8.2 KiB
Python
"""Telegram-бот поверх парсера: лента, абузы, источники, прогон, автодобавление.
|
||
|
||
Запуск: OMNI_BOT_TOKEN=... python -m omni.bot
|
||
Доступ: OMNI_BOT_OWNERS=7448683285,123 (если пусто — только первый написавший)
|
||
|
||
Бот не постит никуда сам: только отвечает в личный чат тем, кто в списке.
|
||
Команды:
|
||
/new [n] последние записи
|
||
/abuse [тег] абузы: promo trial keys leak tool resale
|
||
/find <текст> поиск по базе
|
||
/src <текст> поиск источника
|
||
/add <url> добавить источник (сначала реальная проверка фида)
|
||
/run <категория> прогнать категорию
|
||
/auto [n] автодобавление новых фидов
|
||
/stats цифры
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
import os
|
||
import re
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
from . import abuse as abuse_mod
|
||
from . import discover as discover_mod
|
||
from . import feeds, net, source_state, sources, store
|
||
|
||
API = "https://api.telegram.org/bot%s/" % os.environ.get("OMNI_BOT_TOKEN", "")
|
||
TOKEN = os.environ.get("OMNI_BOT_TOKEN", "")
|
||
OWNERS = {int(x) for x in re.findall(r"\d{5,}", os.environ.get("OMNI_BOT_OWNERS") or os.environ.get("OMNI_BOT_ALLOW") or "")}
|
||
MAX_LEN = 3600
|
||
|
||
|
||
def _api(method, **params):
|
||
if not TOKEN:
|
||
raise SystemExit("OMNI_BOT_TOKEN не задан")
|
||
url = API + method
|
||
data = urllib.parse.urlencode(params).encode()
|
||
req = urllib.request.Request(url, data=data)
|
||
with urllib.request.urlopen(req, timeout=40) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
|
||
|
||
def _send(chat_id, text, preview=True):
|
||
text = text[:MAX_LEN]
|
||
return _api("sendMessage", chat_id=chat_id, text=text, parse_mode="HTML",
|
||
disable_web_page_preview="false" if preview else "true")
|
||
|
||
|
||
def _esc(s):
|
||
return html.escape(str(s or ""), quote=False)
|
||
|
||
|
||
def fmt_item(it):
|
||
title = _esc((it.get("title") or "")[:150])
|
||
src = _esc(it.get("source") or "")
|
||
url = it.get("url") or ""
|
||
line = "<b>%s</b>\n<code>%s</code>" % (title, src)
|
||
if url:
|
||
line += ' · <a href="%s">открыть</a>' % _esc(url)
|
||
return line
|
||
|
||
|
||
def cmd_start(chat_id, arg):
|
||
_send(chat_id, "<b>omni</b> — парсер-радар\n\n"
|
||
"/new 5 — последние записи\n"
|
||
"/abuse promo — абузы по тегу\n"
|
||
"/find claude — поиск\n"
|
||
"/src reddit — источник\n"
|
||
"/add <url> — подключить фид\n"
|
||
"/run abuse — прогнать категорию\n"
|
||
"/auto 10 — автодобавление\n"
|
||
"/stats — цифры")
|
||
|
||
|
||
def cmd_new(chat_id, arg):
|
||
n = int(arg) if arg.strip().isdigit() else 8
|
||
rows = store.browse2(limit=min(n, 20), sort="fresh")
|
||
items = rows.get("items", rows) if isinstance(rows, dict) else rows
|
||
if not items:
|
||
return _send(chat_id, "пусто")
|
||
_send(chat_id, "последние %d:\n\n" % len(items) + "\n\n".join(fmt_item(i) for i in items))
|
||
|
||
|
||
def cmd_abuse(chat_id, arg):
|
||
tag = arg.strip().split()[0] if arg.strip() else None
|
||
d = abuse_mod.scan(limit=10, tag=tag)
|
||
if not d["items"]:
|
||
return _send(chat_id, "абузов не видно")
|
||
head = "абузы · %s · %d\n\n" % ("все" if not tag else tag, d["total"])
|
||
body = []
|
||
for it in d["items"]:
|
||
body.append("<b>%s</b>\n<code>%s</code> · %s" % (
|
||
_esc(it["title"][:120]), _esc(it["source"]), _esc(",".join(it["tags"]))))
|
||
_send(chat_id, head + "\n\n".join(body))
|
||
|
||
|
||
def cmd_find(chat_id, arg):
|
||
q = arg.strip()
|
||
if not q:
|
||
return _send(chat_id, "что искать?")
|
||
rows = store.search(q, limit=8)
|
||
items = rows.get("items", rows) if isinstance(rows, dict) else rows
|
||
if not items:
|
||
return _send(chat_id, "нет совпадений")
|
||
_send(chat_id, "поиск: %s\n\n" % _esc(q) + "\n\n".join(fmt_item(i) for i in items))
|
||
|
||
|
||
def cmd_src(chat_id, arg):
|
||
q = arg.strip().lower()
|
||
all_s = sources.all_sources()
|
||
hits = [s for s in all_s if q and (q in s.name.lower() or q in s.url.lower() or q in s.category.lower())]
|
||
if not hits:
|
||
return _send(chat_id, "нет такого источника")
|
||
out = ["<b>%s</b> · %s · %s" % (_esc(s.name), _esc(s.category), _esc(s.adapter)) for s in hits[:12]]
|
||
_send(chat_id, "источники (%d):\n\n" % len(hits) + "\n".join(out))
|
||
|
||
|
||
def cmd_add(chat_id, arg):
|
||
url = arg.strip()
|
||
if not url.startswith("http"):
|
||
return _send(chat_id, "нужен url фида")
|
||
ok, n, ad = discover_mod.probe(url, "community", limit=6)
|
||
if not ok:
|
||
return _send(chat_id, "фид пустой или недоступен: %s" % _esc(ad))
|
||
name = "manual_" + re.sub(r"[^a-z0-9]+", "_", urllib.parse.urlsplit(url).netloc.lower())[:32]
|
||
source_state.add_custom(name, "community", url, kind="rss" if ad == "rss" else "html",
|
||
adapter=ad, tags=["manual"], limit=15)
|
||
_send(chat_id, "добавлен <code>%s</code> · %s · %d записей" % (_esc(name), _esc(ad), n))
|
||
|
||
|
||
def cmd_run(chat_id, arg):
|
||
cat = arg.strip() or "abuse"
|
||
srcs = [s for s in sources.all_sources() if s.category == cat]
|
||
if not srcs:
|
||
return _send(chat_id, "нет категории %s" % _esc(cat))
|
||
_send(chat_id, "прогон %s (%d источников)…" % (_esc(cat), len(srcs)))
|
||
got = new = 0
|
||
for s in srcs[:80]:
|
||
try:
|
||
items = feeds.fetch(s, 10)
|
||
except Exception:
|
||
items = []
|
||
got += len(items)
|
||
if items:
|
||
new += store.add(items)
|
||
_send(chat_id, "готово: собрано %d, новых %d" % (got, new))
|
||
|
||
|
||
def cmd_auto(chat_id, arg):
|
||
n = int(arg) if arg.strip().isdigit() else 15
|
||
res = discover_mod.discover(limit_hosts=30, workers=8, max_add=min(n, 30))
|
||
_send(chat_id, "автодобавление: кандидатов %d, живых %d\n%s" % (
|
||
res["found"], res["alive"], _esc(", ".join(res["added"][:10]))))
|
||
|
||
|
||
def cmd_stats(chat_id, arg):
|
||
s = store.stats()
|
||
a = sources.all_sources()
|
||
alive = source_state.alive_count() if hasattr(source_state, "alive_count") else "?"
|
||
_send(chat_id, "<b>omni</b>\nзаписей %s · источников %d · живых %s\nкатегорий %d · прогонов %s · с ценой %s" % (
|
||
s.get("items"), len(a), alive, len(sources.CATEGORIES), s.get("runs"), s.get("priced")))
|
||
|
||
|
||
CMDS = {"/start": cmd_start, "/help": cmd_start, "/new": cmd_new, "/abuse": cmd_abuse,
|
||
"/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run,
|
||
"/auto": cmd_auto, "/stats": cmd_stats}
|
||
|
||
|
||
def handle(update):
|
||
msg = update.get("message") or update.get("edited_message") or {}
|
||
chat = msg.get("chat") or {}
|
||
chat_id = chat.get("id")
|
||
text = (msg.get("text") or "").strip()
|
||
uid = (msg.get("from") or {}).get("id")
|
||
if not chat_id or not text:
|
||
return
|
||
if OWNERS and uid not in OWNERS:
|
||
return
|
||
if not OWNERS:
|
||
OWNERS.add(uid) # первый написавший становится владельцем до рестарта
|
||
parts = text.split(None, 1)
|
||
cmd = parts[0].lower().split("@")[0]
|
||
arg = parts[1] if len(parts) > 1 else ""
|
||
fn = CMDS.get(cmd)
|
||
if not fn:
|
||
return _send(chat_id, "не знаю такую команду. /help")
|
||
try:
|
||
fn(chat_id, arg)
|
||
except Exception as e:
|
||
_send(chat_id, "ошибка: %s" % _esc(type(e).__name__ + ": " + str(e)[:160]))
|
||
|
||
|
||
def run_forever():
|
||
if not TOKEN:
|
||
raise SystemExit("нужен OMNI_BOT_TOKEN")
|
||
offset = 0
|
||
print("bot up", flush=True)
|
||
while True:
|
||
try:
|
||
r = _api("getUpdates", offset=offset, timeout=30)
|
||
except Exception as e:
|
||
print("poll fail", e, flush=True)
|
||
time.sleep(5)
|
||
continue
|
||
for up in r.get("result", []):
|
||
offset = up["update_id"] + 1
|
||
handle(up)
|
||
time.sleep(0.3)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_forever() |