"""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())