uni-notify: Outlook + Teams → Telegram с LLM-фильтром

- Playwright держит открытыми веб-версии Outlook и Teams (постоянный профиль, Xvfb)
- watcher.js перехватывает уведомления клиента и изменения карточек в реальном времени
- pipeline: дедуп, baseline, VIP/MUTE, оценка важности через Haiku (OpenRouter)
- aiogram-бот: уведомления, дайджест, вопросы по журналу, /login с 2FA-кодом, ручное управление браузером
- Docker/compose, systemd-юнит, e2e-тест на поддельных страницах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194buWJjpDjU52JPX7PjsMs
This commit is contained in:
Claude
2026-11-13 14:20:14 +01:00
commit da28d0ac1c
22 changed files with 1844 additions and 0 deletions

104
app/config.py Normal file
View File

@@ -0,0 +1,104 @@
"""Настройки из переменных окружения (.env)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
ROOT = Path(__file__).resolve().parent.parent
def _list(name: str) -> list[str]:
return [x.strip() for x in os.getenv(name, "").split(",") if x.strip()]
def _bool(name: str, default: bool) -> bool:
v = os.getenv(name)
if v is None or v == "":
return default
return v.strip().lower() in ("1", "true", "yes", "on")
def _quiet(v: str | None) -> tuple[int, int] | None:
# "23-8" -> (23, 8)
if not v:
return None
try:
a, b = v.split("-")
return int(a), int(b)
except ValueError:
return None
@dataclass(frozen=True)
class Settings:
tg_token: str
owner_id: int
openrouter_key: str
model: str
uni_email: str
uni_password: str | None
my_name: str
mail_url: str
teams_url: str
data_dir: Path
headless: bool
chromium_path: str | None
notify_min: int
quiet_hours: tuple[int, int] | None
digest_time: str | None
tz: str
reload_hours: float
scan_interval: int
batch_window: float
vip: list[str]
mute: list[str]
rules_path: Path
selectors_path: Path
@property
def profile_dir(self) -> Path:
return self.data_dir / "browser-profile"
@property
def db_path(self) -> Path:
return self.data_dir / "uni.db"
def load() -> Settings:
missing = [n for n in ("TG_TOKEN", "OWNER_ID", "OPENROUTER_API_KEY", "UNI_EMAIL") if not os.getenv(n)]
if missing:
raise SystemExit(f"Не заданы переменные окружения: {', '.join(missing)} (см. .env.example)")
data_dir = Path(os.getenv("DATA_DIR", ROOT / "data"))
data_dir.mkdir(parents=True, exist_ok=True)
return Settings(
tg_token=os.environ["TG_TOKEN"],
owner_id=int(os.environ["OWNER_ID"]),
openrouter_key=os.environ["OPENROUTER_API_KEY"],
model=os.getenv("LLM_MODEL", "~anthropic/claude-haiku-latest"),
uni_email=os.environ["UNI_EMAIL"],
uni_password=os.getenv("UNI_PASSWORD") or None,
my_name=os.getenv("MY_NAME", ""),
mail_url=os.getenv("MAIL_URL", "https://outlook.office.com/mail/"),
teams_url=os.getenv("TEAMS_URL", "https://teams.microsoft.com/v2/"),
data_dir=data_dir,
headless=_bool("HEADLESS", False),
chromium_path=os.getenv("CHROMIUM_PATH") or None,
notify_min=int(os.getenv("NOTIFY_MIN_LEVEL", "2")),
quiet_hours=_quiet(os.getenv("QUIET_HOURS")),
digest_time=os.getenv("DIGEST_TIME") or None,
tz=os.getenv("TZ", "Europe/Warsaw"),
reload_hours=float(os.getenv("RELOAD_HOURS", "6")),
scan_interval=int(os.getenv("SCAN_INTERVAL", "120")),
batch_window=float(os.getenv("BATCH_WINDOW", "4")),
vip=_list("VIP"),
mute=_list("MUTE"),
rules_path=Path(os.getenv("RULES_PATH", ROOT / "config" / "rules.md")),
selectors_path=Path(os.getenv("SELECTORS_PATH", ROOT / "config" / "selectors.json")),
)