tg2max-relay: peresylka otchyotov report-shot v MAX (lichnyy akkaunt, vkmax)

This commit is contained in:
khadzhibekovpa
2026-09-17 17:17:18 +03:00
commit 36cf1060d7
11 changed files with 542 additions and 0 deletions
View File
+64
View File
@@ -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),
)
+125
View File
@@ -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()
+51
View File
@@ -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: тестовое сообщение с сервера")
+163
View File
@@ -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())