From da28d0ac1cea1c98d52ec899f64472f2a43e5791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:20:14 +0100 Subject: [PATCH] =?UTF-8?q?uni-notify:=20Outlook=20+=20Teams=20=E2=86=92?= =?UTF-8?q?=20Telegram=20=D1=81=20LLM-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_0194buWJjpDjU52JPX7PjsMs --- .env.example | 43 ++++++ .gitignore | 5 + Dockerfile | 20 +++ README.md | 94 +++++++++++++ app/__init__.py | 0 app/bot.py | 274 ++++++++++++++++++++++++++++++++++++++ app/browser/__init__.py | 0 app/browser/login.py | 178 +++++++++++++++++++++++++ app/browser/session.py | 213 +++++++++++++++++++++++++++++ app/browser/watcher.js | 123 +++++++++++++++++ app/config.py | 104 +++++++++++++++ app/db.py | 158 ++++++++++++++++++++++ app/llm.py | 124 +++++++++++++++++ app/main.py | 73 ++++++++++ app/pipeline.py | 264 ++++++++++++++++++++++++++++++++++++ config/rules.md | 7 + config/selectors.json | 19 +++ deploy/uni-notify.service | 15 +++ docker-compose.yml | 13 ++ requirements.txt | 4 + tests/__init__.py | 0 tests/test_e2e.py | 113 ++++++++++++++++ 22 files changed, 1844 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/bot.py create mode 100644 app/browser/__init__.py create mode 100644 app/browser/login.py create mode 100644 app/browser/session.py create mode 100644 app/browser/watcher.js create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/llm.py create mode 100644 app/main.py create mode 100644 app/pipeline.py create mode 100644 config/rules.md create mode 100644 config/selectors.json create mode 100644 deploy/uni-notify.service create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_e2e.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b86da2e --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# --- Telegram --- +# токен бота от @BotFather +TG_TOKEN=123456:ABC... +# твой Telegram ID (узнать: @userinfobot); бот отвечает только тебе +OWNER_ID=123456789 + +# --- LLM (OpenRouter) --- +OPENROUTER_API_KEY=sk-or-... +# всегда последняя Haiku; можно зафиксировать, напр. anthropic/claude-haiku-4.5 +LLM_MODEL=~anthropic/claude-haiku-latest + +# --- Универ --- +UNI_EMAIL=s123456@uni.edu +# можно оставить пустым — бот спросит пароль в Telegram и удалит сообщение +UNI_PASSWORD= +# чтобы LLM не считала важными твои собственные сообщения +MY_NAME=Имя Фамилия +MAIL_URL=https://outlook.office.com/mail/ +TEAMS_URL=https://teams.microsoft.com/v2/ + +# --- Фильтры --- +# 3 = только срочное, 2 = важное и срочное +NOTIFY_MIN_LEVEL=2 +# через запятую: фамилии преподов, предметы — всегда срочно +VIP= +# через запятую: никогда не присылать +MUTE=newsletter,noreply +# в эти часы «важное» приходит без звука, «срочное» — со звуком +QUIET_HOURS=23-8 +# ежедневный дайджест неважного (пусто = выключен) +DIGEST_TIME=20:00 +TZ=Europe/Warsaw + +# --- Технические --- +# в Docker работает под xvfb, оставь false +HEADLESS=false +# плановая перезагрузка вкладок (утечки памяти Teams) +RELOAD_HOURS=6 +# страховочный пересмотр списков, сек +SCAN_INTERVAL=120 +# сколько секунд копить события перед вызовом LLM +BATCH_WINDOW=4 +# CHROMIUM_PATH=/usr/bin/google-chrome # только для запуска без Docker со своим Chrome diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8d6c50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +__pycache__/ +*.pyc +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58ab669 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ + DATA_DIR=/data + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt \ + && playwright install --with-deps chromium \ + && apt-get update && apt-get install -y --no-install-recommends xvfb xauth fonts-dejavu fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* + +COPY app ./app +COPY config ./config + +# Chromium в обычном (не headless) режиме на виртуальном дисплее: +# Teams/Outlook ведут себя как в настоящем браузере. +CMD ["xvfb-run", "-a", "-s", "-screen 0 1440x960x24", "python", "-m", "app.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf5a24b --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# uni-notify + +Читает университетскую почту (Outlook) и Microsoft Teams через их веб-версии и присылает важное в Telegram. Ему можно задавать вопросы вроде «что препод писал про курсач?». + +## Как это работает + +``` +Chromium (Playwright, постоянный профиль, Xvfb) + ├─ вкладка Outlook ─┐ watcher.js ловит: + └─ вкладка Teams ──┤ 1) уведомления самого клиента (Notification / showNotification) — мгновенно + │ 2) изменения карточек в списках писем/чатов (MutationObserver) — страховка + ▼ + pipeline: дедуп → baseline → VIP/MUTE → LLM (Haiku через OpenRouter) → уровень 0–3 + ▼ + Telegram-бот (aiogram): 🔴 срочно / 🟡 важно / дайджест / ответы на вопросы +``` + +- **Реалтайм.** Веб-клиенты Microsoft сами получают пуши. Бот держит их открытыми и перехватывает уведомления прямо в странице, поэтому задержка составляет секунды. Токены из браузера никуда не выносятся. +- **Сессия.** Профиль браузера хранится в `data/browser-profile`. Вход нужен один раз, дальше клиент сам обновляет токены. Если сессия всё-таки слетит, бот напишет «нужен /login». +- **2FA.** `/login` заполняет обычную форму входа Microsoft, а 6-значный код спрашивает у тебя в Telegram. Если включено сопоставление чисел, бот присылает число, которое нужно выбрать в Authenticator. +- **Нестандартный SSO университета.** Бот пришлёт скриншот. Дальше управляешь браузером через `/click X Y`, `/type`, `/key Enter`, `/screen`. +- **Хранилище.** SQLite с полнотекстовым поиском (`data/uni.db`). По нему бот отвечает на вопросы. + +## Установка (Docker) + +```bash +git clone https://git.azimkin.dev/Azimkin/uni-notify.git && cd uni-notify +cp .env.example .env && nano .env # токен бота, OWNER_ID, ключ OpenRouter, UNI_EMAIL +docker compose up -d --build +docker compose logs -f +``` + +Потом в Telegram: `/start`, затем `/login`, затем введи код из приложения. + +Нужно примерно 1,5–2 ГБ RAM: Teams в браузере прожорлив. Вкладки перезагружаются раз в `RELOAD_HOURS`. + +### Без Docker + +```bash +sudo apt install -y xvfb +python3 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt && playwright install --with-deps chromium +xvfb-run -a python -m app.main +``` + +systemd-юнит лежит в `deploy/uni-notify.service`. + +## Первый запуск: что важно знать + +1. **Baseline.** При самом первом старте всё, что уже лежит в списках, молча запоминается в течение 90 секунд. Старые письма не придут. +2. **Включи уведомления в веб-клиентах** (настройки хранятся в аккаунте, можно сделать со своего браузера): + - Outlook: Settings → General → Notifications → Desktop notifications → включить; + - Teams: Settings → Notifications → включить уведомления для чатов, упоминаний и активности. + + Без этого бот всё равно работает через карточки списков, просто чуть менее точно. +3. **Проверка парсера:** `/debug mail` и `/debug teams` показывают, какие карточки видит бот. Если пусто, у Microsoft поменялась вёрстка: поправь `rowSelector` в `config/selectors.json` и перезапусти. + +## Команды бота + +| Команда | Что делает | +|---|---| +| любой текст | вопрос по журналу событий («какие дедлайны на неделе?») | +| `/status` | состояние сессий, статистика за сутки | +| `/recent [N]` | последние важные события | +| `/digest` | дайджест неважного прямо сейчас | +| `/login` | войти заново | +| `/vip слово`, `/mute слово`, `/lists` | всегда срочно / никогда / показать списки | +| `/screen [mail\|teams]` | скриншот вкладки | +| `/use`, `/click X Y`, `/type`, `/key`, `/goto` | ручное управление браузером | +| `/reload`, `/debug` | перезагрузка вкладки / что видит парсер | + +## Настройка важности + +- `config/rules.md`: правила на человеческом языке. LLM читает файл при каждой оценке, перезапуск не нужен. +- `.env`: `VIP`, `MUTE`, `NOTIFY_MIN_LEVEL`, `QUIET_HOURS`, `DIGEST_TIME`. + +Уровни: **3** срочно (со звуком даже в тихие часы), **2** важно, **1** в дайджест, **0** игнор. Если LLM недоступна, бот присылает всё подряд: лучше лишнее, чем пропустить. + +## Стоимость + +Haiku через OpenRouter: одна пачка событий занимает примерно 1–2 тыс. токенов. При сотне событий в день выходит порядка $1–3 в месяц. + +## Тест + +```bash +CHROMIUM_PATH=/usr/bin/google-chrome python -m tests.test_e2e +``` + +Тест поднимает поддельные страницы Outlook и Teams и проверяет перехват уведомлений, дедуп, baseline, VIP/MUTE и устойчивость к «тикающему» времени в карточках. + +## Риски + +- Автоматизация веб-клиента может противоречить правилам ИТ университета. Худший реалистичный исход: блокировка сессии или вопросы от админов. +- Пароль, если указан в `.env`, хранится на сервере открытым текстом. Можно его не указывать: бот спросит пароль в Telegram при `/login` и сразу удалит сообщение. Пароль держится только в памяти. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..9dd8185 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,274 @@ +"""Telegram-бот (aiogram 3): уведомления, команды и вопросы по журналу событий.""" +from __future__ import annotations + +import asyncio +import html +import logging +import time +from datetime import datetime +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from aiogram import Bot, Dispatcher, F, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.filters import Command, CommandObject +from aiogram.types import BotCommand, BufferedInputFile, Message + +from .config import Settings +from .db import DB +from .llm import LLM + +if TYPE_CHECKING: + from .browser.session import BrowserManager + from .pipeline import Pipeline + +log = logging.getLogger(__name__) + +HELP = """uni-notify — слежу за почтой и Teams, пишу о важном. + +Основное +/status — состояние сессий и статистика +/recent [N] — последние важные события +/digest — дайджест неважного прямо сейчас +/login — войти заново (спрошу 2FA-код) +Просто напиши вопрос — отвечу по журналу: «что препод писал про курсач?» + +Фильтры +/vip слово — всегда срочно (имя препода, предмет) +/mute слово — никогда не присылать +/lists — показать списки, /unvip и /unmute — удалить + +Браузер +/screen [mail|teams] — скриншот вкладки +/use mail|teams — какой вкладкой управлять +/click X Y · /type текст · /key Enter · /goto [url] +/reload [mail|teams] — перезагрузить вкладку +/debug [mail|teams] — что видит парсер списков""" + + +class TgBot: + def __init__(self, s: Settings, db: DB, llm: LLM): + self.s = s + self.db = db + self.llm = llm + self.bot = Bot(s.tg_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + self.dp = Dispatcher() + self.router = Router() + self.router.message.filter(F.from_user.id == s.owner_id) + self.dp.include_router(self.router) + self.browser: BrowserManager | None = None + self.pipeline: Pipeline | None = None + self.tz = ZoneInfo(s.tz) + self._pending: asyncio.Future | None = None + self._pending_secret = False + self._active_tab = "mail" + self._history: list[dict] = [] + self._register() + + def attach(self, browser: "BrowserManager", pipeline: "Pipeline") -> None: + self.browser = browser + self.pipeline = pipeline + + # ---------- API для остальных модулей ---------- + async def say(self, text: str, silent: bool = False, photo: bytes | None = None) -> None: + try: + if photo: + await self.bot.send_photo(self.s.owner_id, BufferedInputFile(photo, "screen.png"), + caption=text[:1024], disable_notification=silent) + else: + for i in range(0, len(text), 4000): + await self.bot.send_message(self.s.owner_id, text[i:i + 4000], + disable_notification=silent, disable_web_page_preview=True) + except Exception: + log.exception("Не удалось отправить в Telegram") + + async def ask(self, prompt: str, secret: bool = False, timeout: float = 300) -> str | None: + if self._pending and not self._pending.done(): + self._pending.cancel() + self._pending = asyncio.get_running_loop().create_future() + self._pending_secret = secret + await self.say(prompt) + try: + return await asyncio.wait_for(self._pending, timeout) + except (asyncio.TimeoutError, asyncio.CancelledError): + return None + finally: + self._pending = None + self._pending_secret = False + + # ---------- хэндлеры ---------- + def _tab(self, arg: str | None) -> str: + a = (arg or "").strip().lower() + if a in ("mail", "почта", "m"): + return "mail" + if a in ("teams", "t", "тимс"): + return "teams" + return self._active_tab + + def _register(self) -> None: + r = self.router + + @r.message(Command("start", "help")) + async def _help(m: Message): + await m.answer(HELP) + + @r.message(Command("status")) + async def _status(m: Message): + st = self.browser.states() if self.browser else {} + stats = self.db.stats() + names = {"ok": "✅ работает", "login": "🔒 нужен /login", "other": "❓ чужая страница", "closed": "💀 закрыта"} + lines = ["Состояние"] + for tab, val in st.items(): + last = self.browser.last_event.get(tab) if self.browser else None + ago = f", событие {int((time.time() - last) / 60)} мин назад" if last else "" + lines.append(f"{'📧' if tab == 'mail' else '💬'} {tab}: {names.get(val, val)}{ago}") + for src, d in stats["day"].items(): + lines.append(f"\n{src} за сутки: " + ", ".join(f"{k}={v}" for k, v in d.items())) + lines.append(f"\nМодель: {html.escape(self.s.model)}, порог уведомлений: {self.s.notify_min}") + await m.answer("\n".join(lines)) + + @r.message(Command("login")) + async def _login(m: Message): + await m.answer("Начинаю вход…") + asyncio.create_task(self.browser.login(self.ask)) + + @r.message(Command("screen")) + async def _screen(m: Message, command: CommandObject): + tab = self._tab(command.args) + shot = await self.browser.screenshot(tab) + await m.answer_photo(BufferedInputFile(shot, f"{tab}.png"), + caption=f"{tab}: {html.escape(self.browser.pages[tab].url[:200])}") + + @r.message(Command("use")) + async def _use(m: Message, command: CommandObject): + self._active_tab = self._tab(command.args) + await m.answer(f"Управляю вкладкой: {self._active_tab}") + + @r.message(Command("click")) + async def _click(m: Message, command: CommandObject): + try: + x, y = (float(v) for v in (command.args or "").split()[:2]) + except ValueError: + return await m.answer("Формат: /click X Y (координаты со скриншота 1400×900)") + await self.browser.click(self._active_tab, x, y) + await asyncio.sleep(1.5) + await _screen(m, CommandObject(prefix="/", command="screen", args=self._active_tab)) + + @r.message(Command("type")) + async def _type(m: Message, command: CommandObject): + if not command.args: + return await m.answer("Формат: /type текст") + await self.browser.type(self._active_tab, command.args) + try: + await m.delete() # вдруг это пароль + except Exception: + pass + await m.answer("⌨️ Напечатал (сообщение удалил).") + + @r.message(Command("key")) + async def _key(m: Message, command: CommandObject): + await self.browser.key(self._active_tab, (command.args or "Enter").strip()) + await asyncio.sleep(1.5) + await _screen(m, CommandObject(prefix="/", command="screen", args=self._active_tab)) + + @r.message(Command("goto")) + async def _goto(m: Message, command: CommandObject): + await self.browser.goto(self._active_tab, (command.args or "").strip() or None) + await asyncio.sleep(2) + await _screen(m, CommandObject(prefix="/", command="screen", args=self._active_tab)) + + @r.message(Command("reload")) + async def _reload(m: Message, command: CommandObject): + await self.browser.reload(self._tab(command.args) if command.args else None) + await m.answer("🔄 Перезагрузил.") + + @r.message(Command("debug")) + async def _debug(m: Message, command: CommandObject): + import json + d = await self.browser.debug(self._tab(command.args)) + await m.answer(f"
{html.escape(json.dumps(d, ensure_ascii=False, indent=1)[:3800])}
") + + @r.message(Command("recent")) + async def _recent(m: Message, command: CommandObject): + n = int(command.args) if (command.args or "").isdigit() else 10 + items = self.db.recent(limit=min(n, 50), min_level=1) + if not items: + return await m.answer("Пока пусто.") + out = [] + for it in reversed(items): + t = datetime.fromtimestamp(it.created_at, self.tz).strftime("%d.%m %H:%M") + icon = {3: "🔴", 2: "🟡", 1: "⚪️"}.get(it.level or 0, "·") + out.append(f"{icon} {t} {html.escape((it.sender or '')[:40])}: " + f"{html.escape(it.summary or it.short(160))}") + await m.answer("\n".join(out)) + + @r.message(Command("digest")) + async def _digest(m: Message): + await self.pipeline.send_digest(manual=True) + + def _list_cmd(name: str, add: bool): + async def h(m: Message, command: CommandObject): + val = (command.args or "").strip().lower() + cur: list[str] = self.db.get(name, []) + if not val: + return await m.answer(f"Формат: /{'' if add else 'un'}{name} слово") + if add and val not in cur: + cur.append(val) + if not add: + cur = [c for c in cur if c != val] + self.db.set(name, cur) + await m.answer(f"{name}: {', '.join(cur) or '(пусто)'}") + return h + + r.message(Command("vip"))(_list_cmd("vip", True)) + r.message(Command("unvip"))(_list_cmd("vip", False)) + r.message(Command("mute"))(_list_cmd("mute", True)) + r.message(Command("unmute"))(_list_cmd("mute", False)) + + @r.message(Command("lists")) + async def _lists(m: Message): + vip = [*self.s.vip, *self.db.get("vip", [])] + mute = [*self.s.mute, *self.db.get("mute", [])] + await m.answer(f"VIP: {html.escape(', '.join(vip) or '—')}\nMute: {html.escape(', '.join(mute) or '—')}") + + @r.message(F.text & ~F.text.startswith("/")) + async def _text(m: Message): + # ответ на вопрос бота (2FA-код, пароль) + if self._pending and not self._pending.done(): + if self._pending_secret: + try: + await m.delete() + except Exception: + pass + self._pending.set_result(m.text) + return + await self._qa(m) + + async def _qa(self, m: Message) -> None: + await self.bot.send_chat_action(m.chat.id, "typing") + found = self.db.search(m.text, limit=25) + recent = self.db.recent(limit=40) + seen, items = set(), [] + for it in [*found, *recent]: + if it.id not in seen: + seen.add(it.id) + items.append(it) + items.sort(key=lambda i: i.created_at) + try: + ans = await self.llm.answer(m.text, items, self._history) + except Exception as e: + return await m.answer(f"LLM недоступна: {html.escape(str(e)[:300])}") + self._history += [{"role": "user", "content": m.text}, {"role": "assistant", "content": ans}] + self._history = self._history[-12:] + await m.answer(html.escape(ans), parse_mode=ParseMode.HTML) + + async def run(self) -> None: + await self.bot.set_my_commands([ + BotCommand(command=c, description=d) for c, d in [ + ("status", "Состояние"), ("recent", "Последние важные"), ("digest", "Дайджест"), + ("login", "Войти заново"), ("screen", "Скриншот вкладки"), ("lists", "VIP/Mute"), + ("help", "Помощь"), + ] + ]) + await self.dp.start_polling(self.bot, handle_signals=False) diff --git a/app/browser/__init__.py b/app/browser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/browser/login.py b/app/browser/login.py new file mode 100644 index 0000000..cda0a86 --- /dev/null +++ b/app/browser/login.py @@ -0,0 +1,178 @@ +"""Автоматический вход в Microsoft 365 своим логином/паролем с 2FA-кодом, который спрашивается в Telegram. + +Ничего не обходит: заполняет обычную форму входа так же, как это сделал бы человек. +Для нестандартных SSO-страниц университета присылает скриншот — дальше можно +управлять браузером командами /click, /type, /key. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable +from urllib.parse import urlparse + +from playwright.async_api import Page + +log = logging.getLogger(__name__) + +MS_LOGIN_HOSTS = ("login.microsoftonline.com", "login.live.com", "login.microsoft.com", "account.live.com") + +Say = Callable[..., Awaitable[None]] +Ask = Callable[..., Awaitable[str | None]] + + +def is_login_url(url: str) -> bool: + host = urlparse(url).hostname or "" + return any(host.endswith(h) for h in MS_LOGIN_HOSTS) + + +async def _visible(page: Page, sel: str) -> bool: + try: + loc = page.locator(sel).first + return await loc.count() > 0 and await loc.is_visible() + except Exception: + return False + + +async def _text(page: Page, sel: str) -> str: + try: + loc = page.locator(sel).first + if await loc.count() and await loc.is_visible(): + return (await loc.inner_text()).strip() + except Exception: + pass + return "" + + +async def _click(page: Page, sel: str) -> None: + await page.locator(sel).first.click(timeout=5000) + + +async def login_flow(page: Page, *, target_url: str, target_host: str, email: str, + password: str | None, say: Say, ask: Ask, timeout: float = 420) -> bool: + """Ведёт страницу через вход Microsoft до target_host. True — вошли.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + if not is_login_url(page.url) and target_host not in (urlparse(page.url).hostname or ""): + await page.goto(target_url, wait_until="domcontentloaded") + + told_number = told_push = told_unknown = False + unknown_since: float | None = None + otc_tries = 0 + last_step = "" + + while loop.time() < deadline: + await asyncio.sleep(1.5) + url = page.url + host = urlparse(url).hostname or "" + + # --- успех --- + if target_host in host and not is_login_url(url): + await asyncio.sleep(3) + if target_host in (urlparse(page.url).hostname or ""): + return True + continue + + # --- нестандартная страница (SSO университета и т.п.) --- + if not is_login_url(url): + unknown_since = unknown_since or loop.time() + if not told_unknown and loop.time() - unknown_since > 12: + told_unknown = True + shot = await page.screenshot() + await say( + f"Нестандартная страница входа: {host}\n" + "Управляй браузером: /click X Y, /type текст, /key Enter, /screen — " + "я продолжу, как только вернёмся на Microsoft или в почту.", + photo=shot, + ) + continue + unknown_since = None + + # --- ошибки --- + err = await _text(page, "#usernameError, #passwordError, #idSpan_SAOTCC_Error_OTC, #service_exception_message") + if err and err != last_step: + last_step = err + await say(f"Microsoft пишет: {err}") + if "password" in err.lower() or "пароль" in err.lower() or "hasło" in err.lower(): + password = None # спросим заново + + # --- «Оставаться в системе?» --- + if await _visible(page, "#KmsiCheckboxField") or await _visible(page, "#KmsiDescription"): + if await _visible(page, "#KmsiCheckboxField"): + try: + await page.locator("#KmsiCheckboxField").check(timeout=3000) + except Exception: + pass + await _click(page, "#idSIButton9") + continue + + # --- ввод кода из приложения --- + if await _visible(page, "input[name='otc']"): + if otc_tries >= 3: + await say("Три неверных кода подряд — останавливаюсь. /login чтобы начать заново.") + return False + code = await ask("🔐 Введи 6-значный код из приложения-аутентификатора:", timeout=240) + if not code: + await say("Код не пришёл — вход отменён.") + return False + otc_tries += 1 + await page.locator("input[name='otc']").first.fill(code.strip().replace(" ", "")) + if await _visible(page, "#idSubmit_SAOTCC_Continue"): + await _click(page, "#idSubmit_SAOTCC_Continue") + else: + await page.keyboard.press("Enter") + try: # ждём, пока поле исчезнет (успех) — иначе это ошибка кода + await page.locator("input[name='otc']").first.wait_for(state="hidden", timeout=12000) + except Exception: + pass + continue + + # --- сопоставление чисел в Authenticator --- + number = await _text(page, "#idRichContext_DisplaySign") + if number and not told_number: + told_number = True + await say(f"📱 Открой Authenticator и выбери число {number}") + continue + + # --- выбор способа подтверждения: предпочитаем код --- + if await _visible(page, "[data-value='PhoneAppOTP']"): + await _click(page, "[data-value='PhoneAppOTP']") + continue + + # --- push «Approve sign in» без числа --- + if await _visible(page, "#idDiv_SAOTCAS_Title") and not told_push: + told_push = True + await say("📱 Подтверди вход в приложении Authenticator (или /screen, если что-то не так).") + continue + + # --- пароль --- + if await _visible(page, "input[name='passwd']"): + if not password: + password = await ask("🔑 Пароль от универского аккаунта (сообщение сразу удалю):", secret=True, timeout=240) + if not password: + await say("Пароль не пришёл — вход отменён.") + return False + await page.locator("input[name='passwd']").first.fill(password) + await _click(page, "#idSIButton9") + await asyncio.sleep(2) + continue + + # --- email --- + if await _visible(page, "input[name='loginfmt']"): + await page.locator("input[name='loginfmt']").first.fill(email) + await _click(page, "#idSIButton9") + await asyncio.sleep(2) + continue + + # --- выбор аккаунта --- + tile = page.get_by_text(email, exact=False) + try: + if await tile.count() and await tile.first.is_visible(): + await tile.first.click(timeout=5000) + continue + except Exception: + pass + + shot = await page.screenshot() + await say("⏱ Вход не завершился за отведённое время. Вот что на экране:", photo=shot) + return False diff --git a/app/browser/session.py b/app/browser/session.py new file mode 100644 index 0000000..49fc830 --- /dev/null +++ b/app/browser/session.py @@ -0,0 +1,213 @@ +"""Постоянно открытые Outlook и Teams в Chromium (Playwright, persistent profile).""" +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import Awaitable, Callable +from urllib.parse import urlparse + +from playwright.async_api import BrowserContext, Page, async_playwright + +from ..config import Settings +from .login import is_login_url, login_flow + +log = logging.getLogger(__name__) + +WATCHER_JS = (Path(__file__).parent / "watcher.js").read_text(encoding="utf-8") + +OnEvent = Callable[[dict], Awaitable[None]] +Say = Callable[..., Awaitable[None]] + + +class BrowserManager: + def __init__(self, s: Settings, on_event: OnEvent, say: Say): + self.s = s + self.on_event = on_event + self.say = say + self.cfg = json.loads(s.selectors_path.read_text(encoding="utf-8")) + self.hosts = {src["name"]: src["host"] for src in self.cfg["sources"]} + self.urls = {"mail": s.mail_url, "teams": s.teams_url} + self.pw = None + self.ctx: BrowserContext | None = None + self.pages: dict[str, Page] = {} + self.last_event: dict[str, float] = {} + self.login_lock = asyncio.Lock() + self._state: dict[str, str] = {} + self._last_reload = time.time() + self.password_cache: str | None = s.uni_password + + # ---------- запуск ---------- + async def start(self) -> None: + self.pw = await async_playwright().start() + self.ctx = await self.pw.chromium.launch_persistent_context( + user_data_dir=str(self.s.profile_dir), + headless=self.s.headless, + executable_path=self.s.chromium_path, + viewport={"width": 1400, "height": 900}, + locale="en-US", + timezone_id=self.s.tz, + permissions=["notifications"], + args=["--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", + "--disable-blink-features=AutomationControlled"], + ) + await self.ctx.expose_binding("__uniEmit", self._binding) + init = f"window.__UNI_CFG = {json.dumps(self.cfg)};\n{WATCHER_JS}" + await self.ctx.add_init_script(script=init) + + existing = list(self.ctx.pages) + for name in ("mail", "teams"): + page = existing.pop(0) if existing else await self.ctx.new_page() + await self._attach(name, page) + for extra in existing: + await extra.close() + for name in self.pages: + await self._goto(name) + + async def _attach(self, name: str, page: Page) -> None: + self.pages[name] = page + page.on("crash", lambda _p, n=name: asyncio.create_task(self._recreate(n, "crash"))) + + async def _recreate(self, name: str, why: str) -> None: + log.warning("Пересоздаю вкладку %s (%s)", name, why) + try: + await self.pages[name].close() + except Exception: + pass + await self._attach(name, await self.ctx.new_page()) + await self._goto(name) + + async def _goto(self, name: str) -> None: + try: + await self.pages[name].goto(self.urls[name], wait_until="domcontentloaded", timeout=60000) + except Exception as e: + log.warning("Не открылась %s: %s", name, e) + + async def _binding(self, source, payload: str) -> None: + try: + ev = json.loads(payload) + except Exception: + return + self.last_event[ev.get("source", "?")] = time.time() + await self.on_event(ev) + + # ---------- состояние ---------- + def state(self, name: str) -> str: + page = self.pages.get(name) + if not page or page.is_closed(): + return "closed" + url = page.url + if is_login_url(url): + return "login" + if self.hosts[name] in (urlparse(url).hostname or ""): + return "ok" + return "other" + + def states(self) -> dict[str, str]: + return {n: self.state(n) for n in self.pages} + + async def screenshot(self, name: str) -> bytes: + return await self.pages[name].screenshot() + + async def debug(self, name: str) -> dict: + try: + return await self.pages[name].evaluate("window.__uniDebug ? window.__uniDebug() : null") or {} + except Exception as e: + return {"error": str(e)} + + async def reload(self, name: str | None = None) -> None: + for n in [name] if name else list(self.pages): + try: + await self.pages[n].reload(wait_until="domcontentloaded", timeout=60000) + except Exception as e: + log.warning("reload %s: %s", n, e) + await self._recreate(n, "reload failed") + + # ---------- ручное управление (для нестандартного SSO) ---------- + async def click(self, name: str, x: float, y: float) -> None: + await self.pages[name].mouse.click(x, y) + + async def type(self, name: str, text: str) -> None: + await self.pages[name].keyboard.type(text, delay=30) + + async def key(self, name: str, key: str) -> None: + await self.pages[name].keyboard.press(key) + + async def goto(self, name: str, url: str | None = None) -> None: + await self.pages[name].goto(url or self.urls[name], wait_until="domcontentloaded", timeout=60000) + + # ---------- вход ---------- + async def login(self, ask) -> bool: + if self.login_lock.locked(): + await self.say("Вход уже идёт.") + return False + async def ask_cached(prompt: str, secret: bool = False, timeout: float = 300): + v = await ask(prompt, secret=secret, timeout=timeout) + if secret and v: + self.password_cache = v # только в памяти, на диск не пишется + return v + + async with self.login_lock: + ask = ask_cached + ok = await login_flow( + self.pages["mail"], target_url=self.urls["mail"], target_host=self.hosts["mail"], + email=self.s.uni_email, password=self.password_cache, say=self.say, ask=ask, + ) + if not ok: + return False + await self.say("✅ Почта открыта. Захожу в Teams…") + # Teams обычно подхватывает SSO-сессию сам, максимум просит выбрать аккаунт + ok_t = await login_flow( + self.pages["teams"], target_url=self.urls["teams"], target_host=self.hosts["teams"], + email=self.s.uni_email, password=self.password_cache, say=self.say, ask=ask, timeout=180, + ) + await self.say("✅ Teams открыт. Слежу за событиями." if ok_t else "⚠️ Почта работает, а Teams — нет. /screen teams") + return ok_t + + # ---------- фоновые циклы ---------- + async def health_loop(self) -> None: + await asyncio.sleep(45) + while True: + try: + for name in list(self.pages): + st = self.state(name) + prev = self._state.get(name) + self._state[name] = st + if st == "closed": + await self._recreate(name, "closed") + elif st == "login" and prev != "login": + await self.say( + f"⚠️ {'Почта' if name == 'mail' else 'Teams'}: сессия слетела, нужен вход. Жми /login" + ) + elif st == "ok" and prev == "login": + await self.say(f"✅ {'Почта' if name == 'mail' else 'Teams'} снова на связи.") + if self.s.reload_hours > 0 and time.time() - self._last_reload > self.s.reload_hours * 3600: + if not self.login_lock.locked(): + log.info("Плановая перезагрузка вкладок") + self._last_reload = time.time() + await self.reload() + except Exception: + log.exception("health_loop") + await asyncio.sleep(60) + + async def scan_loop(self) -> None: + """Страховка: раз в N секунд просим страницу пересмотреть списки (на случай пропущенных мутаций).""" + while True: + await asyncio.sleep(self.s.scan_interval) + for name, page in list(self.pages.items()): + if self.state(name) != "ok": + continue + try: + await page.evaluate("window.__uniScan && window.__uniScan(false)") + except Exception: + pass + + async def close(self) -> None: + try: + if self.ctx: + await self.ctx.close() + finally: + if self.pw: + await self.pw.stop() diff --git a/app/browser/watcher.js b/app/browser/watcher.js new file mode 100644 index 0000000..c713368 --- /dev/null +++ b/app/browser/watcher.js @@ -0,0 +1,123 @@ +// Встраивается в страницы Outlook/Teams до загрузки их скриптов (add_init_script). +// Ловит события двумя путями: +// 1) уведомления самого клиента (window.Notification и ServiceWorkerRegistration.showNotification); +// 2) изменения карточек в списке писем/чатов (MutationObserver), как страховка. +// Всё отправляется в Python через window.__uniEmit(json). +// Конфиг (селекторы) подставляется в window.__UNI_CFG перед этим скриптом. +(() => { + 'use strict'; + if (window.top !== window) return; // только верхний фрейм + if (window.__uniWatcher) return; + + const CFG = window.__UNI_CFG || {}; + const SRC = (CFG.sources || []).find((s) => location.host.includes(s.host)); + if (!SRC) return; // логин-страницы и прочее — пропускаем + window.__uniWatcher = true; + + const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim(); + const emit = (ev) => { + ev.source = SRC.name; + ev.url = location.href; + ev.ts = Date.now(); + try { + if (typeof window.__uniEmit === 'function') window.__uniEmit(JSON.stringify(ev)); + } catch (e) { /* binding недоступен — игнор */ } + }; + + // ---------- 1. Уведомления ---------- + class UniNotification extends EventTarget { + constructor(title, opts) { + super(); + opts = opts || {}; + this.title = String(title || ''); + this.body = String(opts.body || ''); + this.tag = String(opts.tag || ''); + this.data = opts.data; + this.icon = opts.icon || ''; + this.onclick = this.onclose = this.onshow = this.onerror = null; + emit({ kind: 'notification', title: norm(this.title), text: norm(this.body), tag: this.tag }); + setTimeout(() => { + try { if (this.onshow) this.onshow(new Event('show')); this.dispatchEvent(new Event('show')); } catch (e) {} + }, 0); + } + close() { try { if (this.onclose) this.onclose(new Event('close')); } catch (e) {} } + static get permission() { return 'granted'; } + static requestPermission(cb) { if (typeof cb === 'function') cb('granted'); return Promise.resolve('granted'); } + static get maxActions() { return 2; } + } + try { + Object.defineProperty(window, 'Notification', { value: UniNotification, writable: true, configurable: true }); + } catch (e) { window.Notification = UniNotification; } + + if (window.ServiceWorkerRegistration && ServiceWorkerRegistration.prototype.showNotification) { + ServiceWorkerRegistration.prototype.showNotification = function (title, opts) { + opts = opts || {}; + emit({ kind: 'notification', title: norm(title), text: norm(opts.body), tag: String(opts.tag || '') }); + return Promise.resolve(); + }; + } + + // Клиенты часто не показывают уведомления, если вкладка в фокусе. + if (CFG.fakeBlur !== false) { + try { Document.prototype.hasFocus = function () { return false; }; } catch (e) {} + } + + // ---------- 2. Карточки в списках ---------- + const seen = new Set(); + + function leafMatches(sel) { + const nodes = Array.from(document.querySelectorAll(sel)); + // оставляем самые «внутренние» совпадения: группа-папка с чатами внутри не нужна + return nodes.filter((n) => !nodes.some((m) => m !== n && n.contains(m))); + } + + function readRows() { + if (!SRC.rowSelector || !document.body) return []; + const nodes = leafMatches(SRC.rowSelector).slice(0, SRC.maxRows || 25); + return nodes.map((n) => { + let id = ''; + for (const a of SRC.idAttrs || []) { const v = n.getAttribute(a); if (v) { id = v; break; } } + const lines = String(n.innerText || n.textContent || '').split('\n').map(norm).filter(Boolean); + return { id, lines: lines.slice(0, 10), aria: norm(n.getAttribute('aria-label')).slice(0, 600) }; + }).filter((r) => r.lines.length || r.aria); + } + + // Строки вида «12:45», «Yesterday», «5 мин» меняются сами по себе — не считаем их изменением. + const TIME_RE = /^(\d{1,2}[:.]\d{2}(\s?[AP]M)?|\d{1,2}[./]\d{1,2}([./]\d{2,4})?|\d+\s?\S{0,5}|now|teraz|сейчас)$/i; + const INLINE_TIME_RE = /\b\d{1,2}[:.]\d{2}(\s?[AP]M)?\b/gi; + const sigOf = (r) => r.id + '|' + r.lines.filter((l) => !TIME_RE.test(l)).map((l) => l.replace(INLINE_TIME_RE, '')).join('|'); + + function scan(force) { + let rows; + try { rows = readRows(); } catch (e) { return 0; } + let n = 0; + for (const r of rows) { + const sig = sigOf(r); + if (!force && seen.has(sig)) continue; + seen.add(sig); + n++; + emit({ kind: 'row', id: r.id, lines: r.lines, aria: r.aria }); + } + if (seen.size > 3000) seen.clear(); + return n; + } + + let timer = null; + let lastRun = 0; + const schedule = () => { + if (timer) return; + const wait = Math.max(1200, 2500 - (Date.now() - lastRun)); + timer = setTimeout(() => { timer = null; lastRun = Date.now(); scan(false); }, wait); + }; + + const start = () => { + new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true, characterData: true }); + schedule(); + }; + if (document.body) start(); + else document.addEventListener('DOMContentLoaded', start, { once: true }); + + // Для Python: принудительный пересмотр и отладка + window.__uniScan = (force) => scan(!!force); + window.__uniDebug = () => ({ source: SRC.name, title: document.title, url: location.href, rows: readRows().slice(0, 5) }); +})(); diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..57b6726 --- /dev/null +++ b/app/config.py @@ -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")), + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..0fafe49 --- /dev/null +++ b/app/db.py @@ -0,0 +1,158 @@ +"""SQLite-хранилище событий (с полнотекстовым поиском FTS5) и key-value настроек.""" +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, -- mail | teams + kind TEXT NOT NULL, -- notification | row + key TEXT NOT NULL, + sender TEXT, + title TEXT, + body TEXT, + created_at REAL NOT NULL, + level INTEGER, -- 0..3, NULL = ещё не оценено + summary TEXT, + status TEXT NOT NULL DEFAULT 'new', -- new | sent | digest | digested | skipped | baseline | dup + UNIQUE(source, key) +); +CREATE INDEX IF NOT EXISTS items_created ON items(created_at); + +CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( + sender, title, body, summary, content='items', content_rowid='id', tokenize='unicode61' +); +CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN + INSERT INTO items_fts(rowid, sender, title, body, summary) + VALUES (new.id, new.sender, new.title, new.body, new.summary); +END; +CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN + INSERT INTO items_fts(items_fts, rowid, sender, title, body, summary) + VALUES ('delete', old.id, old.sender, old.title, old.body, old.summary); + INSERT INTO items_fts(rowid, sender, title, body, summary) + VALUES (new.id, new.sender, new.title, new.body, new.summary); +END; + +CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL); +""" + + +@dataclass +class Item: + id: int + source: str + kind: str + key: str + sender: str + title: str + body: str + created_at: float + level: int | None + summary: str | None + status: str + + def short(self, n: int = 300) -> str: + parts = [p for p in (self.title, self.body) if p] + s = " — ".join(parts) + return s if len(s) <= n else s[: n - 1] + "…" + + +class DB: + def __init__(self, path: Path): + self._lock = threading.Lock() + self.conn = sqlite3.connect(str(path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.executescript(SCHEMA) + self.conn.commit() + + # ---------- items ---------- + def insert(self, source: str, kind: str, key: str, sender: str, title: str, body: str, + status: str = "new") -> int | None: + """Возвращает id новой записи или None, если такое событие уже было.""" + with self._lock: + cur = self.conn.execute( + "INSERT OR IGNORE INTO items(source, kind, key, sender, title, body, created_at, status)" + " VALUES (?,?,?,?,?,?,?,?)", + (source, kind, key, sender, title, body, time.time(), status), + ) + self.conn.commit() + return cur.lastrowid if cur.rowcount else None + + def update(self, item_id: int, **fields) -> None: + if not fields: + return + cols = ", ".join(f"{k}=?" for k in fields) + with self._lock: + self.conn.execute(f"UPDATE items SET {cols} WHERE id=?", (*fields.values(), item_id)) + self.conn.commit() + + def _rows(self, sql: str, args: tuple = ()) -> list[Item]: + with self._lock: + return [Item(**dict(r)) for r in self.conn.execute(sql, args).fetchall()] + + def recent(self, limit: int = 20, min_level: int = 0) -> list[Item]: + return self._rows( + "SELECT * FROM items WHERE status NOT IN ('baseline','dup') AND COALESCE(level,0) >= ?" + " ORDER BY created_at DESC LIMIT ?", + (min_level, limit), + ) + + def digest_pending(self) -> list[Item]: + return self._rows("SELECT * FROM items WHERE status='digest' ORDER BY created_at") + + def search(self, query: str, limit: int = 25) -> list[Item]: + words = [w for w in re.findall(r"\w{3,}", query.lower())][:12] + if not words: + return [] + # префиксный поиск по каждому слову, OR — ранжирует bm25 + fts_q = " OR ".join(f'"{w}"*' for w in words) + try: + return self._rows( + "SELECT items.* FROM items_fts JOIN items ON items.id = items_fts.rowid" + " WHERE items_fts MATCH ? AND items.status NOT IN ('dup')" + " ORDER BY bm25(items_fts) LIMIT ?", + (fts_q, limit), + ) + except sqlite3.OperationalError: + return [] + + def stats(self) -> dict: + with self._lock: + day_ago = time.time() - 86400 + r = self.conn.execute( + "SELECT source, status, COUNT(*) c FROM items WHERE created_at > ? GROUP BY source, status", + (day_ago,), + ).fetchall() + last = self.conn.execute( + "SELECT source, MAX(created_at) t FROM items GROUP BY source" + ).fetchall() + out: dict = {"day": {}, "last": {x["source"]: x["t"] for x in last}} + for x in r: + out["day"].setdefault(x["source"], {})[x["status"]] = x["c"] + return out + + def count_source(self, source: str) -> int: + with self._lock: + return self.conn.execute("SELECT COUNT(*) FROM items WHERE source=?", (source,)).fetchone()[0] + + # ---------- kv ---------- + def get(self, key: str, default=None): + with self._lock: + r = self.conn.execute("SELECT value FROM kv WHERE key=?", (key,)).fetchone() + return json.loads(r[0]) if r else default + + def set(self, key: str, value) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO kv(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, json.dumps(value, ensure_ascii=False)), + ) + self.conn.commit() diff --git a/app/llm.py b/app/llm.py new file mode 100644 index 0000000..17901d1 --- /dev/null +++ b/app/llm.py @@ -0,0 +1,124 @@ +"""LLM через OpenRouter (OpenAI-совместимый API): оценка важности и ответы на вопросы.""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime +from zoneinfo import ZoneInfo + +import httpx + +from .db import Item + +log = logging.getLogger(__name__) + +TRIAGE_SYSTEM = """Ты — фильтр уведомлений для студента. На вход приходят события из университетской почты (Outlook) и Microsoft Teams. +Событие бывает двух видов: +- notification — системное уведомление клиента (title обычно отправитель/чат, text — текст сообщения); +- row — карточка из списка писем/чатов, которая появилась или изменилась (строки: обычно отправитель или название чата, тема, превью, время). + +Для каждого события поставь уровень важности: +3 — срочно: дедлайн в ближайшие 48 часов, перенос/отмена занятия или экзамена на сегодня-завтра, преподаватель прямо просит ответить или что-то сделать, оценки/результаты, всё, что требует действия сегодня. +2 — важно: письма и сообщения от преподавателей, деканата, администрации; новые задания; изменения расписания; упоминание пользователя (@); личные сообщения от людей. +1 — в дайджест: общие рассылки университета, объявления без действия, обычная болтовня в групповых чатах без упоминания. +0 — игнор: спам, реклама, новостные рассылки, автоматические письма без пользы, собственные сообщения пользователя (начинаются с «Вы:», «You:», «Ty:» или от его имени), бессмысленные изменения карточек. + +Правила пользователя ниже важнее общих правил. + +Ответь ТОЛЬКО JSON-массивом, без пояснений: +[{"i": <номер>, "level": <0-3>, "sender": "<кто>", "summary": "<1–2 предложения по-русски: кто, что, что нужно сделать и до когда>"}]""" + +ANSWER_SYSTEM = """Ты — личный ассистент студента. У тебя есть журнал событий из его университетской почты (Outlook) и Microsoft Teams. +Отвечай по-русски, коротко и по делу, опираясь только на журнал ниже. Указывай, от кого и когда пришло. +Если в журнале нет ответа — честно скажи, что не нашёл, и предложи, где посмотреть. +Помни: журнал — это превью/уведомления, а не полные тексты писем. Сегодня: {now}.""" + + +def _extract_json(text: str): + text = text.strip() + text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.M).strip() + for opener, closer in (("[", "]"), ("{", "}")): + a, b = text.find(opener), text.rfind(closer) + if a != -1 and b > a: + try: + return json.loads(text[a : b + 1]) + except json.JSONDecodeError: + continue + raise ValueError(f"LLM вернула не JSON: {text[:200]}") + + +class LLM: + def __init__(self, api_key: str, model: str, tz: str): + self.model = model + self.tz = ZoneInfo(tz) + self.http = httpx.AsyncClient( + base_url="https://openrouter.ai/api/v1", + headers={ + "Authorization": f"Bearer {api_key}", + "HTTP-Referer": "https://github.com/Azimkin/uni-notify", + "X-Title": "uni-notify", + }, + timeout=httpx.Timeout(60, connect=15), + ) + + async def chat(self, system: str, messages: list[dict], max_tokens: int = 1200, temperature: float = 0.2) -> str: + payload = { + "model": self.model, + "messages": [{"role": "system", "content": system}, *messages], + "max_tokens": max_tokens, + "temperature": temperature, + } + r = await self.http.post("/chat/completions", json=payload) + if r.status_code >= 400: + raise RuntimeError(f"OpenRouter {r.status_code}: {r.text[:300]}") + data = r.json() + return data["choices"][0]["message"]["content"] or "" + + async def triage(self, events: list[dict], rules: str, my_name: str) -> list[dict]: + """events: [{source, kind, sender, title, body}] → [{i, level, sender, summary}]""" + lines = [] + for i, e in enumerate(events): + body = (e.get("body") or "")[:1200] + lines.append( + f"#{i} [{e['source']}/{e['kind']}]\n" + f"title: {e.get('title') or e.get('sender') or ''}\n" + f"text: {body}" + ) + system = TRIAGE_SYSTEM + if my_name: + system += f"\n\nИмя пользователя: {my_name}." + if rules.strip(): + system += "\n\nПравила пользователя:\n" + rules.strip() + raw = await self.chat(system, [{"role": "user", "content": "\n\n".join(lines)}], max_tokens=300 + 150 * len(events)) + data = _extract_json(raw) + if isinstance(data, dict): + data = data.get("items") or data.get("result") or [data] + out = [] + for d in data: + try: + out.append({ + "i": int(d["i"]), + "level": max(0, min(3, int(d.get("level", 2)))), + "sender": str(d.get("sender") or "").strip(), + "summary": str(d.get("summary") or "").strip(), + }) + except (KeyError, TypeError, ValueError): + continue + return out + + def _fmt_item(self, it: Item) -> str: + t = datetime.fromtimestamp(it.created_at, self.tz).strftime("%d.%m %H:%M") + src = "Почта" if it.source == "mail" else "Teams" + txt = it.summary or it.short(500) + return f"[{t}, {src}] {it.sender or '?'}: {txt}" + (f"\n (оригинал: {it.short(400)})" if it.summary else "") + + async def answer(self, question: str, items: list[Item], history: list[dict]) -> str: + now = datetime.now(self.tz).strftime("%A %d.%m.%Y %H:%M") + journal = "\n".join(self._fmt_item(i) for i in items) or "(журнал пуст)" + system = ANSWER_SYSTEM.format(now=now) + "\n\nЖурнал событий:\n" + journal + msgs = [*history[-8:], {"role": "user", "content": question}] + return (await self.chat(system, msgs, max_tokens=900, temperature=0.3)).strip() + + async def aclose(self): + await self.http.aclose() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d265b97 --- /dev/null +++ b/app/main.py @@ -0,0 +1,73 @@ +"""Точка входа: python -m app.main""" +from __future__ import annotations + +import asyncio +import logging +import signal + +from . import config +from .bot import TgBot +from .browser.session import BrowserManager +from .db import DB +from .llm import LLM +from .pipeline import Pipeline + +log = logging.getLogger("uni-notify") + + +async def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + logging.getLogger("aiogram.event").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + + s = config.load() + db = DB(s.db_path) + llm = LLM(s.openrouter_key, s.model, s.tz) + tg = TgBot(s, db, llm) + pipeline = Pipeline(s, db, llm, tg.say) + browser = BrowserManager(s, pipeline.submit, tg.say) + tg.attach(browser, pipeline) + + await browser.start() + await tg.say("🚀 uni-notify запущен. Проверяю сессии…", silent=True) + + async def first_check(): + await asyncio.sleep(25) + st = browser.states() + if all(v == "ok" for v in st.values()): + await tg.say("✅ Почта и Teams открыты, слежу.", silent=True) + else: + await tg.say(f"Состояние: {st}. Если нужен вход — /login") + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + pass + + tasks = [ + asyncio.create_task(tg.run(), name="telegram"), + asyncio.create_task(pipeline.run(), name="pipeline"), + asyncio.create_task(pipeline.digest_loop(), name="digest"), + asyncio.create_task(browser.health_loop(), name="health"), + asyncio.create_task(browser.scan_loop(), name="scan"), + asyncio.create_task(first_check(), name="first_check"), + ] + waiter = asyncio.create_task(stop.wait()) + done, _ = await asyncio.wait([waiter, *tasks[:2]], return_when=asyncio.FIRST_COMPLETED) + for t in done: + if t is not waiter and t.exception(): + log.error("Задача %s упала: %r", t.get_name(), t.exception()) + + log.info("Останавливаюсь…") + for t in tasks: + t.cancel() + await browser.close() + await llm.aclose() + await tg.bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/pipeline.py b/app/pipeline.py new file mode 100644 index 0000000..68a99bf --- /dev/null +++ b/app/pipeline.py @@ -0,0 +1,264 @@ +"""События из браузера → дедуп → правила → LLM-оценка → уведомление в Telegram.""" +from __future__ import annotations + +import asyncio +import hashlib +import html +import logging +import re +import time +from collections import deque +from datetime import datetime, timedelta +from typing import Awaitable, Callable +from zoneinfo import ZoneInfo + +from .config import Settings +from .db import DB +from .llm import LLM + +log = logging.getLogger(__name__) + +TIME_RE = re.compile( + r"^(\d{1,2}[:.]\d{2}(\s?[AP]M)?|\d{1,2}[./]\d{1,2}([./]\d{2,4})?|\d+\s?\S{0,5}|now|teraz|сейчас)$", re.I +) +ICON = {3: "🔴", 2: "🟡", 1: "⚪️", 0: "·"} +SRC_NAME = {"mail": "📧 Почта", "teams": "💬 Teams"} +BASELINE_SECONDS = 90 + +Say = Callable[..., Awaitable[None]] + + +def norm(s: str) -> str: + return re.sub(r"\s+", " ", s or "").strip().lower() + + +def sha(s: str) -> str: + return hashlib.sha1(s.encode()).hexdigest()[:16] + + +INLINE_TIME_RE = re.compile(r"\b\d{1,2}[:.]\d{2}(\s?[AP]M)?\b", re.I) + + +def stable_lines(lines: list[str]) -> list[str]: + """Строки карточки без «тикающих» частей (время, «5 мин»).""" + cand = [INLINE_TIME_RE.sub("", l).strip() for l in lines if not TIME_RE.match(l.strip())] + cand = [l for l in cand if l] + long = [l for l in cand if len(l) > 15] + return long or cand + + +class Pipeline: + def __init__(self, s: Settings, db: DB, llm: LLM, say: Say): + self.s = s + self.db = db + self.llm = llm + self.say = say + self.q: asyncio.Queue[dict] = asyncio.Queue() + self.recent_texts: deque[tuple[float, str]] = deque(maxlen=300) + self.baseline_until: dict[str, float] = {} + self.tz = ZoneInfo(s.tz) + + # ---------- вход ---------- + async def submit(self, ev: dict) -> None: + await self.q.put(ev) + + def _rules(self) -> str: + try: + return self.s.rules_path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + def _lists(self) -> tuple[list[str], list[str]]: + vip = [*self.s.vip, *self.db.get("vip", [])] + mute = [*self.s.mute, *self.db.get("mute", [])] + return [v.lower() for v in vip], [m.lower() for m in mute] + + # ---------- нормализация ---------- + def _to_item(self, ev: dict) -> dict | None: + src = ev.get("source") + if src not in ("mail", "teams"): + return None + kind = ev.get("kind") + if kind == "notification": + title, text = ev.get("title", ""), ev.get("text", "") + if not (title or text): + return None + bucket = int(time.time() // 600) # одинаковые уведомления в разные 10-минутки — разные события + return {"source": src, "kind": kind, "key": f"n:{sha(norm(title) + '|' + norm(text))}:{bucket}", + "sender": title, "title": "", "body": text, "fp": norm(text)[:80]} + if kind == "row": + lines = [l for l in ev.get("lines", []) if l] + if not lines and not ev.get("aria"): + return None + st = stable_lines(lines) or [ev.get("aria", "")] + key = f"r:{ev.get('id', '')}:{sha('|'.join(norm(l) for l in st))}" + sender = lines[0] if lines else "" + title = lines[1] if src == "mail" and len(lines) > 1 else "" + rest = [l for l in (lines[2:] if title else lines[1:]) if not TIME_RE.match(l)] + body = " | ".join(rest) + if ev.get("aria") and norm(ev["aria"]) not in norm(" ".join(lines)): + body += f"\n[aria] {ev['aria']}" + return {"source": src, "kind": kind, "key": key, "sender": sender, "title": title, + "body": body, "fp": norm(" ".join(lines))} + return None + + def _is_dup(self, it: dict) -> bool: + now = time.time() + while self.recent_texts and now - self.recent_texts[0][0] > 900: + self.recent_texts.popleft() + fp = it["fp"] + for _, other in self.recent_texts: + if not other or not fp: + continue + short, long_ = (fp, other) if len(fp) <= len(other) else (other, fp) + if len(short) >= 12 and short[:60] in long_: + return True + return False + + def _in_baseline(self, source: str) -> bool: + """Первый запуск: всё, что уже было в списках, запоминаем молча.""" + if self.db.get(f"baseline:{source}"): + return False + until = self.baseline_until.get(source) + if until is None: + self.baseline_until[source] = time.time() + BASELINE_SECONDS + log.info("Baseline для %s: %s сек молча запоминаю текущие списки", source, BASELINE_SECONDS) + return True + if time.time() < until: + return True + self.db.set(f"baseline:{source}", True) + return False + + # ---------- основной цикл ---------- + async def run(self) -> None: + while True: + first = await self.q.get() + batch = [first] + deadline = time.monotonic() + self.s.batch_window + while len(batch) < 20: + left = deadline - time.monotonic() + if left <= 0: + break + try: + batch.append(await asyncio.wait_for(self.q.get(), left)) + except asyncio.TimeoutError: + break + try: + await self.process(batch) + except Exception: + log.exception("Ошибка обработки пачки") + + async def process(self, events: list[dict]) -> None: + # уведомления раньше карточек — тогда карточка того же сообщения отсеется как дубль + events = sorted(events, key=lambda e: 0 if e.get("kind") == "notification" else 1) + fresh: list[tuple[int, dict]] = [] + for ev in events: + it = self._to_item(ev) + if not it: + continue + if it["kind"] == "row" and self._in_baseline(it["source"]): + self.db.insert(it["source"], it["kind"], it["key"], it["sender"], it["title"], it["body"], status="baseline") + continue + dup = self._is_dup(it) + item_id = self.db.insert(it["source"], it["kind"], it["key"], it["sender"], it["title"], it["body"], + status="dup" if dup else "new") + if item_id is None: + continue # уже видели + self.recent_texts.append((time.time(), it["fp"])) + if not dup: + fresh.append((item_id, it)) + if not fresh: + return + + vip, mute = self._lists() + to_llm: list[tuple[int, dict]] = [] + forced: dict[int, int] = {} + for item_id, it in fresh: + hay = norm(f"{it['sender']} {it['title']} {it['body']}") + if any(m in hay for m in mute): + self.db.update(item_id, level=0, status="skipped", summary="muted") + continue + if any(v in hay for v in vip): + forced[item_id] = 3 + to_llm.append((item_id, it)) + if not to_llm: + return + + try: + verdicts = await self.llm.triage([it for _, it in to_llm], self._rules(), self.s.my_name) + by_i = {v["i"]: v for v in verdicts} + except Exception as e: + log.warning("LLM недоступна (%s) — шлю без фильтра", e) + by_i = {} + + for i, (item_id, it) in enumerate(to_llm): + v = by_i.get(i, {"level": 2, "summary": "", "sender": ""}) + level = max(v["level"], forced.get(item_id, 0)) + summary = v.get("summary") or "" + sender = v.get("sender") or it["sender"] + if level >= self.s.notify_min: + await self._notify(it, level, summary, sender) + status = "sent" + elif level >= 1: + status = "digest" + else: + status = "skipped" + self.db.update(item_id, level=level, summary=summary, status=status) + + # ---------- отправка ---------- + def _quiet_now(self) -> bool: + if not self.s.quiet_hours: + return False + a, b = self.s.quiet_hours + h = datetime.now(self.tz).hour + return (a <= h or h < b) if a > b else (a <= h < b) + + async def _notify(self, it: dict, level: int, summary: str, sender: str) -> None: + e = html.escape + head = f"{ICON.get(level, '')} {SRC_NAME.get(it['source'], it['source'])} · {e(sender[:80])}" + orig = " — ".join(x for x in (it["title"], it["body"].split("\n[aria]")[0]) if x) + if len(orig) > 600: + orig = orig[:599] + "…" + text = head + if summary: + text += f"\n{e(summary)}" + if orig and norm(orig) != norm(summary): + text += f"\n\n
{e(orig)}
" + silent = level < 3 and self._quiet_now() + await self.say(text, silent=silent) + + # ---------- дайджест ---------- + async def send_digest(self, manual: bool = False) -> None: + items = self.db.digest_pending() + if not items: + if manual: + await self.say("Дайджест пуст — ничего неважного не накопилось.") + return + e = html.escape + parts = [f"🗞 Дайджест ({len(items)})"] + for src in ("mail", "teams"): + group = [i for i in items if i.source == src] + if not group: + continue + parts.append(f"\n{SRC_NAME[src]}") + for it in group[-40:]: + t = datetime.fromtimestamp(it.created_at, self.tz).strftime("%d.%m %H:%M") + parts.append(f"• {t} {e((it.sender or '')[:40])}: {e(it.summary or it.short(150))}") + await self.say("\n".join(parts), silent=True) + for it in items: + self.db.update(it.id, status="digested") + + async def digest_loop(self) -> None: + if not self.s.digest_time: + return + hh, mm = (int(x) for x in self.s.digest_time.split(":")) + while True: + now = datetime.now(self.tz) + target = now.replace(hour=hh, minute=mm, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + await asyncio.sleep(max(5, (target - now).total_seconds())) + try: + await self.send_digest() + except Exception: + log.exception("digest") diff --git a/config/rules.md b/config/rules.md new file mode 100644 index 0000000..cc1480a --- /dev/null +++ b/config/rules.md @@ -0,0 +1,7 @@ +# Мои правила важности (читается LLM при каждой оценке — правь свободно, перезапуск не нужен) + +- Всё от преподавателей и деканата — минимум «важно». +- Упоминания экзаменов, колоквиумов, зачётов, дедлайнов, переносов и отмен пар — «срочно», если это в ближайшие 2 дня. +- Сообщения в чатах моей учебной группы — «в дайджест», кроме случаев, когда меня упоминают или спрашивают лично. +- Рассылки студсовета, мероприятия, опросы — «в дайджест». +- Автоматические письма Moodle/USOS о новых оценках — «срочно». diff --git a/config/selectors.json b/config/selectors.json new file mode 100644 index 0000000..bca04fd --- /dev/null +++ b/config/selectors.json @@ -0,0 +1,19 @@ +{ + "fakeBlur": true, + "sources": [ + { + "name": "mail", + "host": "outlook.", + "rowSelector": "div[role='listbox'] div[role='option']", + "idAttrs": ["data-convid"], + "maxRows": 25 + }, + { + "name": "teams", + "host": "teams.", + "rowSelector": "[role='tree'] [role='treeitem'], [data-tid='chat-list-item']", + "idAttrs": ["data-item-id"], + "maxRows": 25 + } + ] +} diff --git a/deploy/uni-notify.service b/deploy/uni-notify.service new file mode 100644 index 0000000..3f64c76 --- /dev/null +++ b/deploy/uni-notify.service @@ -0,0 +1,15 @@ +# Вариант без Docker: sudo cp deploy/uni-notify.service /etc/systemd/system/ && sudo systemctl enable --now uni-notify +[Unit] +Description=uni-notify (Outlook + Teams -> Telegram) +After=network-online.target +Wants=network-online.target + +[Service] +WorkingDirectory=/opt/uni-notify +EnvironmentFile=/opt/uni-notify/.env +ExecStart=/usr/bin/xvfb-run -a -s "-screen 0 1440x960x24" /opt/uni-notify/.venv/bin/python -m app.main +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c23d98 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + uni-notify: + build: . + container_name: uni-notify + restart: unless-stopped + env_file: .env + environment: + DATA_DIR: /data + volumes: + - ./data:/data # профиль браузера (сессия) + SQLite + - ./config:/app/config # rules.md и selectors.json можно править без пересборки + shm_size: "1gb" + mem_limit: 2g diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3d352d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +playwright>=1.50,<2 +aiogram>=3.13,<4 +httpx>=0.27 +python-dotenv>=1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..e6a8649 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,113 @@ +"""Сквозной тест без сети: поддельные страницы Outlook/Teams → watcher.js → pipeline → «Telegram». + +Запуск: CHROMIUM_PATH=/usr/bin/google-chrome python -m tests.test_e2e +""" +import asyncio +import os +import shutil +import tempfile + +TMP = tempfile.mkdtemp(prefix="uni-test-") +os.environ.update(TG_TOKEN="1:x", OWNER_ID="1", OPENROUTER_API_KEY="x", UNI_EMAIL="me@uni.edu", + DATA_DIR=TMP, HEADLESS="true", BATCH_WINDOW="1.5", VIP="dziekanat", MUTE="newsletter") + +from app import config, pipeline as pl # noqa: E402 +from app.browser import session as bs # noqa: E402 +from app.db import DB # noqa: E402 + +pl.BASELINE_SECONDS = 3 + +MAIL_HTML = """Mail - Outlook +
+""" + +TEAMS_HTML = """Chat | Microsoft Teams +
+
Recent +
Group Projekt\\nAnia: ok see you tomorrow everyone\\n12:00
+
+
+""" + + +async def main(): + s = config.load() + db = DB(s.db_path) + sent = [] + + async def say(text, silent=False, photo=None): + sent.append((text, silent)) + print("\n>>> TG", "(silent)" if silent else "", "\n" + text) + + class FakeLLM: + calls = 0 + + async def triage(self, events, rules, my_name): + FakeLLM.calls += 1 + out = [] + for i, e in enumerate(events): + t = (e.get("body", "") + e.get("title", "") + e.get("sender", "")).lower() + lvl = 3 if "kolokwium" in t else 2 if "@me" in t else 1 + out.append({"i": i, "level": lvl, "sender": e.get("sender", ""), "summary": f"[summary] {t[:60]}"}) + return out + + p = pl.Pipeline(s, db, FakeLLM(), say) + bm = bs.BrowserManager(s, p.submit, say) + + orig_goto = bm._goto + + async def goto_with_routes(name): + if not getattr(bm, "_routed", False): + bm._routed = True + await bm.ctx.route("https://outlook.office.com/**", lambda r: r.fulfill(body=MAIL_HTML, content_type="text/html; charset=utf-8")) + await bm.ctx.route("https://teams.microsoft.com/**", lambda r: r.fulfill(body=TEAMS_HTML, content_type="text/html; charset=utf-8")) + await orig_goto(name) + + bm._goto = goto_with_routes + await bm.start() + runner = asyncio.create_task(p.run()) + await asyncio.sleep(14) + print("\nstates:", bm.states()) + print("debug mail:", await bm.debug("mail")) + + rows = db.conn.execute("SELECT source, kind, status, level, sender FROM items ORDER BY id").fetchall() + print("\nDB:") + for r in rows: + print(" ", tuple(r)) + + texts = "\n".join(t for t, _ in sent) + assert bm.states() == {"mail": "ok", "teams": "ok"}, bm.states() + assert texts.count("Kolokwium z analizy") >= 1 and texts.lower().count("kolokwium przeniesione") <= 1, "kolokwium: ровно одно уведомление" + kolok_msgs = [t for t, _ in sent if "kolokwium" in t.lower()] + assert len(kolok_msgs) == 1, f"дубль уведомления: {len(kolok_msgs)}" + assert any("Dziekanat" in t and "🔴" in t for t, _ in sent), "VIP должен быть срочным" + assert not any("number 43" in t for t, _ in sent), "MUTE не должен приходить" + assert any("@me" in t.lower() for t, _ in sent), "Teams: упоминание должно прийти" + assert not any("Old subject" in t for t, _ in sent), "baseline не должен уведомлять" + statuses = {r["status"] for r in rows} + assert "baseline" in statuses + # тиканье времени не создало новое событие для c1 + c1 = [r for r in rows if r["sender"] == "Old Sender"] + assert len(c1) == 1 and c1[0]["status"] == "baseline", c1 + print(f"\n✅ ВСЁ ОК. Сообщений в TG: {len(sent)}, вызовов LLM: {FakeLLM.calls}") + + runner.cancel() + await bm.close() + shutil.rmtree(TMP, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main())