Files
tg2max-relay/app/relay.py
T

164 lines
6.0 KiB
Python

"""tg2max-relay: следит за томом отчётов report-shot и дублирует PDF и
аварийные строки лога в MAX-чат от имени личного аккаунта (vkmax).
report-shot при этом не изменяется совсем."""
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import datetime, timedelta
from pathlib import Path
from .config import RelayConfig
from .maxapi import MaxUser
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("relay")
def load_state(path: Path) -> dict:
try:
return json.loads(path.read_text("utf-8"))
except Exception:
return {"seen_files": [], "log_pos": 0, "seeded": False}
def save_state(path: Path, state: dict) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), "utf-8")
tmp.replace(path)
def report_files(cfg: RelayConfig) -> list[Path]:
out: list[Path] = []
for pat in cfg.report_globs:
out.extend(p for p in cfg.watch_dir.glob(pat) if p.is_file())
return sorted(set(out))
def caption_for(name: str) -> str:
"""otchet_YYYY-MM-DD.pdf = дата формирования; данные за предыдущие сутки."""
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", name)
if m:
d = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
return f"📊 Отчёт report-shot — данные за {(d - timedelta(days=1)):%d.%m.%Y}"
return f"📊 Отчёт report-shot: {name}"
async def tail_alerts(cfg: RelayConfig, state: dict) -> list[str]:
lf: Path = cfg.log_file
if not lf.is_file():
return []
try:
size = lf.stat().st_size
pos = int(state.get("log_pos", 0))
if size < pos: # ротация/перезапись
pos = 0
with lf.open("r", encoding="utf-8", errors="replace") as fh:
fh.seek(pos)
chunk = fh.read()
new_pos = fh.tell()
state["log_pos"] = new_pos
except Exception:
log.exception("не удалосьпрочитать лог")
return []
lines = [l for l in chunk.splitlines() if l.strip()]
if cfg.alert_re:
lines = [l for l in lines if cfg.alert_re.search(l)]
return lines[: cfg.max_lines_per_cycle]
async def try_send(maxu: MaxUser | None, coro, what: str) -> bool:
for attempt in (1, 2):
try:
await coro()
log.info("MAX: %s отправлен", what)
return True
except Exception as err:
log.warning("MAX: отправка (%s) не удалась, попытка %d/2: %s", what, attempt, err)
await maxu.close() if maxu else None
await asyncio.sleep(5)
return False
async def main() -> None:
cfg = RelayConfig.load()
state = load_state(cfg.state_file)
if not (cfg.device_id and cfg.login_token and cfg.target_chat):
log.error("Не заданы MAX_DEVICE_ID/MAX_LOGIN_TOKEN/MAX_TARGET_CHAT — "
"ретранслятор не может войти в MAX. Пройдите логин (см. README) и "
"обновите переменные в Coolify.")
while True:
await asyncio.sleep(3600)
maxu = MaxUser(cfg.device_id, cfg.login_token)
while True:
try:
await maxu.ensure()
break
except Exception as err:
log.warning("MAX: вход не удался (%s), повтор через 180 с", err)
await maxu.close()
await asyncio.sleep(180)
files = report_files(cfg)
if not state["seeded"]:
# помечаем всё уже лежащее как отправленное, чтобы не дублировать историю
state["seen_files"] = [p.name for p in files]
if cfg.log_file.is_file():
state["log_pos"] = cfg.log_file.stat().st_size
state["seeded"] = True
save_state(cfg.state_file, state)
log.info("История помечена как отправленная (%d файлов). Ждём новые.", len(files))
if cfg.hello and not state.get("hello_sent"):
if await try_send(maxu, lambda: maxu.send_text(
cfg.target_chat,
f"🟢 tg2max-relay на связи ({datetime.now():%d.%m.%Y %H:%M}) — "
"дублирую отчёты report-shot в этот чат"), "hello"):
state["hello_sent"] = True
save_state(cfg.state_file, state)
seen = set(state.get("seen_files", []))
while True:
try:
now = datetime.now().timestamp()
for p in report_files(cfg):
if p.name in seen:
continue
st = p.stat()
if now - st.st_mtime < cfg.poll_interval + 5:
continue # возможно ещё дописывается
ok = await try_send(
maxu,
lambda p=p: maxu.send_pdf(
cfg.target_chat, p,
caption_for(p.name)),
p.name,
)
if ok:
seen.add(p.name)
state["seen_files"] = sorted(seen)
save_state(cfg.state_file, state)
for line in await tail_alerts(cfg, state):
await try_send(maxu,
lambda line=line: maxu.send_text(
cfg.target_chat, f"⚠️ {line[:900]}"), "alert")
save_state(cfg.state_file, state)
except Exception:
log.exception("цикл релея")
await maxu.close()
try:
await maxu.ensure()
except Exception:
log.exception("переподключение MAX")
await asyncio.sleep(cfg.poll_interval)
if __name__ == "__main__":
asyncio.run(main())