From 36cf1060d76b96a1e8c3767024107590ac68964b Mon Sep 17 00:00:00 2001 From: khadzhibekovpa Date: Thu, 17 Sep 2026 17:17:18 +0300 Subject: [PATCH] tg2max-relay: peresylka otchyotov report-shot v MAX (lichnyy akkaunt, vkmax) --- .env.example | 20 ++++++ .gitignore | 6 ++ Dockerfile | 10 +++ README.md | 72 ++++++++++++++++++++ app/__init__.py | 0 app/config.py | 64 ++++++++++++++++++ app/login.py | 125 ++++++++++++++++++++++++++++++++++ app/maxapi.py | 51 ++++++++++++++ app/relay.py | 163 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 29 ++++++++ requirements.txt | 2 + 11 files changed, 542 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/login.py create mode 100644 app/maxapi.py create mode 100644 app/relay.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e3600a --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Время запуска и часовой пояс +TZ=Europe/Moscow + +# === Обязательное (после python -m app.login на своём ПК) === +# device_id и login_token из max_session.json +MAX_DEVICE_ID= +MAX_LOGIN_TOKEN= +# chatId получателя: «Избранное» = свой user id; чат/группу можно посмотреть +# через python -m app.login --chats +MAX_TARGET_CHAT= + +# === Опциональное === +# Шаблон(ы) имён файлов отчётов через запятую +#REPORT_GLOBS=otchet_*.pdf +# Интервал опроса, сек +#POLL_INTERVAL=10 +# Какие строки из report_shot.log пересылать алертами (regex) +#ALERT_RE=(?i)(⚠|Сбой по расписанию|отчёт неполный|FATAL) +# Отправить приветствие при первом запуске +#RELAY_HELLO=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b3af27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +max_session.json +code.txt +__pycache__/ +*.pyc +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bbae0b4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "-m", "app.relay"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..960caf5 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# tg2max-relay + +Ретранслятор отчётов **report-shot** в мессенджер **MAX** от имени **личного +аккаунта MAX** (без бизнес-бота). Работает в паре с +[ReportEvryDay](https://gittea.khad1997pav.ru/khadzhibekovpa/ReportEvryDay) +и **не изменяет** его: просто читает тот же docker-volume с PDF-отчётами и +хвост лога (для предупреждений о сбоях). + +## Как это работает + +``` +report-shot (тот же сервер) tg2max-relay + writes /app/output/*.pdf ─volume─▶ видит новый otchet_YYYY-MM-DD.pdf + writes report_shot.log шлёт файл+подпись в MAX (vkmax, + личный аккаунт, wss ws-api.oneme.ru) + строки ALERT_RE из лога → текстом +``` + +- `vkmax` (unofficial user-client MAX, GitHub main) — `send_message` / `send_file`. +- Сессия MAX (`device_id` + `login_token`) получается один раз **на своём ПК** + и переносится в переменные Coolify. На сервере ничего интерактивного не нужно. + +## 1. Активация MAX-сессии (один раз, на Windows-ПК) + +```powershell +cd C:\scripts\tg2max-relay +py -m venv .venv +.\.venv\Scripts\activate +pip install -r requirements.txt +python -m app.login --phone +7999XXXXXXX +# введите код из приложения/SMS MAX → сохранится max_session.json +python -m app.login --chats # посмотреть chatId (для MAX_TARGET_CHAT) +python -m app.login --test # проверить отправку +``` + +Для «Избранного» в качестве `MAX_TARGET_CHAT` используется собственный user id +(скрипт подскажет его). + +## 2. Deploy в Coolify (рядом с report-shot) + +Создано как приложение в том же проекте **Report** (build pack +dockercompose). Переменные в **Variables**: + +| Переменная | Значение | +|---|---| +| MAX_DEVICE_ID | из шага 1 | +| MAX_LOGIN_TOKEN | из шага 1 | +| MAX_TARGET_CHAT | chatId (Избранное: свой user id) | +| TZ | Europe/Moscow | + +compose монтирует volume отчётов **read-only** (`external: true`). Имя тома +привязано к UUID приложения report-shot +(`n51q832cjhizblqwbibgjxsc_report-shot-output`). Если report-shot будет +пересоздан с новым uuid — поправьте имя тома здесь и redeploy. + +## 3. Поведение + +- При первом запуске текущее содержимое volume помечается как отправленное + (история не дублируется), шлётся только «🟢 на связи». +- Каждый новый `otchet_*.pdf` (файл дописался и молчит ≥ poll_interval) → + `send_file` с подписью «данные за <вчера>». +- Строки `report_shot.log`, попадающие под `ALERT_RE` + (сбои, неполный отчёт) → текстом в тот же чат. +- Захлебнулся WS/MAX — ретраи 2× и переподключение в следующем цикле; сбой + цикла не роняет контейнер. + +Без `MAX_DEVICE_ID/MAX_LOGIN_TOKEN/MAX_TARGET_CHAT` контейнер живёт, но +сообщает в логе, что вход невозможен. + +## Переменные + +См. `.env.example`. Всё опционально, кроме трёх MAX-ключей. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e460957 --- /dev/null +++ b/app/config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path + + +def _env(key: str, default: str = "") -> str: + return (os.environ.get(key) or default).strip() + + +def _env_int(key: str, default: int) -> int: + raw = _env(key) + try: + return int(raw) if raw else default + except ValueError: + return default + + +def _env_bool(key: str, default: bool) -> bool: + raw = _env(key).lower() + if not raw: + return default + return raw in {"1", "true", "yes", "y", "on"} + + +@dataclass +class RelayConfig: + watch_dir: Path + state_file: Path + poll_interval: int + report_globs: list[str] + alert_re: re.Pattern | None + log_file: Path + device_id: str + login_token: str + target_chat: int + hello: bool + max_lines_per_cycle: int + + @classmethod + def load(cls) -> "RelayConfig": + watch_dir = Path(_env("WATCH_DIR") or "/watch") + state_dir = Path(_env("STATE_DIR") or "/data") + state_dir.mkdir(parents=True, exist_ok=True) + alert_raw = _env("ALERT_RE", r"(?i)(⚠|Сбой по расписанию|отчёт неполный|FATAL)") + try: + alert_re = re.compile(alert_raw) if alert_raw else None + except re.error as err: + raise RuntimeError(f"Некорректный ALERT_RE: {err}") from err + return cls( + watch_dir=watch_dir, + state_file=state_dir / "state.json", + poll_interval=max(5, _env_int("POLL_INTERVAL", 10)), + report_globs=[g.strip() for g in _env("REPORT_GLOBS", "otchet_*.pdf").split(",") if g.strip()], + alert_re=alert_re, + log_file=Path(_env("RELAY_LOG") or (watch_dir / "report_shot.log")), + device_id=_env("MAX_DEVICE_ID"), + login_token=_env("MAX_LOGIN_TOKEN"), + target_chat=_env_int("MAX_TARGET_CHAT", 0), + hello=_env_bool("RELAY_HELLO", True), + max_lines_per_cycle=_env_int("MAX_LINES_PER_CYCLE", 5), + ) diff --git a/app/login.py b/app/login.py new file mode 100644 index 0000000..588a5d9 --- /dev/null +++ b/app/login.py @@ -0,0 +1,125 @@ +"""Разовая активация MAX-сессии (запускать на своём ПК, не на сервере): + + python -m app.login --phone +79991234567 + (попросит код из SMS/пуша MAX, можно через --code-file для автоматики) + python -m app.login --chats # список чатов с chatId (для MAX_TARGET_CHAT) + +Результат: max_session.json (device_id, login_token, my_id) — значения едут в +переменные Coolify: MAX_DEVICE_ID, MAX_LOGIN_TOKEN, MAX_TARGET_CHAT.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path + +from vkmax.client import MaxClient +from vkmax.functions.messages import send_message + +SESSION_FILE = Path( + os.environ.get("MAX_SESSION_FILE") + or Path(__file__).resolve().parent.parent / "max_session.json" +) + + +def _load() -> dict: + return json.loads(SESSION_FILE.read_text("utf-8")) + + +def _find_keys(node, wanted: set[str], out: dict | None = None) -> dict: + if out is None: + out = {} + if isinstance(node, dict): + for k, v in node.items(): + if k in wanted and isinstance(v, (int, str)) and k not in out: + out[k] = v + _find_keys(v, wanted, out) + elif isinstance(node, list): + for v in node[:60]: + _find_keys(v, wanted, out) + return out + + +async def _wait_code(code_file: str | None) -> str: + if code_file: + p = Path(code_file) + print(f"RELAY-AWAIT-CODE: запишите код в {p.resolve()}", flush=True) + while not p.exists(): + await asyncio.sleep(2) + await asyncio.sleep(1) + code = p.read_text(errors="replace").strip() + p.unlink(missing_ok=True) + return code + return input("Код подтверждения из MAX: ").strip() + + +async def login(phone: str, code_file: str | None) -> None: + client = MaxClient() + await client.connect() + sms_token = await client.send_code(phone) + code = await _wait_code(code_file) + resp = await client.sign_in(sms_token, int(code)) + payload = resp["payload"] + login_token = payload["tokenAttrs"]["LOGIN"]["token"] + ids = _find_keys(payload, {"myId", "userId"}) + session = { + "device_id": client.device_id, + "login_token": login_token, + "my_id": ids.get("myId") or ids.get("userId"), + } + SESSION_FILE.write_text(json.dumps(session, indent=2), "utf-8") + await client.disconnect() + print("RELAY-SESSION-DONE", flush=True) + print(json.dumps(session, ensure_ascii=False, indent=1)) + + +async def chats() -> None: + s = _load() + client = MaxClient() + await client.connect() + resp = await client.login_by_token(s["login_token"], s["device_id"]) + payload = resp["payload"] + print("payload keys:", sorted(payload.keys())) + chat_list = payload.get("chats") + if isinstance(chat_list, dict): + chat_list = chat_list.get("chats") or list(chat_list.values()) + if isinstance(chat_list, list): + for c in chat_list[:60]: + cid = c.get("chatId") or c.get("id") + info = c.get("chat") or c.get("lastChat") or c + print(f"- chatId={cid} | title={info.get('title')!r} | type={info.get('type')}") + else: + print(json.dumps(payload, ensure_ascii=False)[:4000]) + await client.disconnect() + + +async def test(chat_id: int) -> None: + s = _load() + client = MaxClient() + await client.connect() + await client.login_by_token(s["login_token"], s["device_id"]) + await send_message(client, chat_id, "🟢 tg2max-relay: проверка сессии (локальный запуск)") + print("TEST-SENT to", chat_id, flush=True) + await client.disconnect() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--phone") + ap.add_argument("--code-file") + ap.add_argument("--chats", action="store_true") + ap.add_argument("--test", type=int, metavar="CHAT_ID") + a = ap.parse_args() + if a.phone: + asyncio.run(login(a.phone, a.code_file)) + elif a.chats: + asyncio.run(chats()) + elif a.test: + asyncio.run(test(a.test)) + else: + ap.print_help() + + +if __name__ == "__main__": + main() diff --git a/app/maxapi.py b/app/maxapi.py new file mode 100644 index 0000000..7e86e88 --- /dev/null +++ b/app/maxapi.py @@ -0,0 +1,51 @@ +"""Тонкая обёртка над vkmax (unofficial user-client MAX), переживающая разрывы WS.""" +from __future__ import annotations + +import asyncio +import logging + +from vkmax.client import MaxClient +from vkmax.functions.messages import send_file as vk_send_file +from vkmax.functions.messages import send_message as vk_send_message + +log = logging.getLogger("relay.maxapi") + + +class MaxUser: + def __init__(self, device_id: str, login_token: str) -> None: + self.device_id = device_id + self.login_token = login_token + self._client: MaxClient | None = None + + async def _new_client(self) -> MaxClient: + await self.close() + client = MaxClient() + await client.connect() + await client.login_by_token(self.login_token, self.device_id) + self._client = client + log.info("MAX: вход выполнен (device=%s)", self.device_id[:8]) + return client + + async def ensure(self) -> MaxClient: + if self._client is None or self._client._connection is None: + return await self._new_client() + return self._client + + async def close(self) -> None: + if self._client is not None: + try: + await self._client.disconnect() + except Exception: + pass + self._client = None + + async def send_text(self, chat_id: int, text: str) -> None: + client = await self.ensure() + await asyncio.wait_for(vk_send_message(client, chat_id, text), 150) + + async def send_pdf(self, chat_id: int, path, caption: str) -> None: + client = await self.ensure() + await asyncio.wait_for(vk_send_file(client, chat_id, str(path), caption), 300) + + async def test_send(self, chat_id: int) -> None: + await self.send_text(chat_id, "🟢 tg2max-relay: тестовое сообщение с сервера") diff --git a/app/relay.py b/app/relay.py new file mode 100644 index 0000000..da7fcc8 --- /dev/null +++ b/app/relay.py @@ -0,0 +1,163 @@ +"""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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6dbab51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + relay: + build: . + image: tg2max-relay:latest + restart: unless-stopped + env_file: + - path: .env + required: false + environment: + TZ: ${TZ:-Europe/Moscow} + WATCH_DIR: /watch + STATE_DIR: /data + MAX_DEVICE_ID: ${MAX_DEVICE_ID:-} + MAX_LOGIN_TOKEN: ${MAX_LOGIN_TOKEN:-} + MAX_TARGET_CHAT: ${MAX_TARGET_CHAT:-} + POLL_INTERVAL: ${POLL_INTERVAL:-10} + REPORT_GLOBS: ${REPORT_GLOBS:-otchet_*.pdf} + ALERT_RE: ${ALERT_RE:-(?i)(⚠|Сбой по расписанию|отчёт неполный|FATAL|ERROR report_shot)} + RELAY_HELLO: ${RELAY_HELLO:-true} + MAX_SESSION_FILE: /data/max_session.json + volumes: + - relay-state:/data + - report-out:/watch:ro + +volumes: + relay-state: + report-out: + name: n51q832cjhizblqwbibgjxsc_report-shot-output + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0887299 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# vkmax с PyPI (1.0.2) устарел и без отправки файлов — берём main из GitHub +https://github.com/nsdkinx/vkmax/archive/refs/heads/main.tar.gz#egg=vkmax