tg2max-relay: peresylka otchyotov report-shot v MAX (lichnyy akkaunt, vkmax)
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
max_session.json
|
||||||
|
code.txt
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
+10
@@ -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"]
|
||||||
@@ -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 <chatId> # проверить отправку
|
||||||
|
```
|
||||||
|
|
||||||
|
Для «Избранного» в качестве `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-ключей.
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# vkmax с PyPI (1.0.2) устарел и без отправки файлов — берём main из GitHub
|
||||||
|
https://github.com/nsdkinx/vkmax/archive/refs/heads/main.tar.gz#egg=vkmax
|
||||||
Reference in New Issue
Block a user