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

43
.env.example Normal file
View File

@@ -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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
data/
__pycache__/
*.pyc
.venv/

20
Dockerfile Normal file
View File

@@ -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"]

94
README.md Normal file
View File

@@ -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` и сразу удалит сообщение. Пароль держится только в памяти.

0
app/__init__.py Normal file
View File

274
app/bot.py Normal file
View File

@@ -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 = """<b>uni-notify</b> — слежу за почтой и Teams, пишу о важном.
<b>Основное</b>
/status — состояние сессий и статистика
/recent [N] — последние важные события
/digest — дайджест неважного прямо сейчас
/login — войти заново (спрошу 2FA-код)
Просто напиши вопрос — отвечу по журналу: «что препод писал про курсач?»
<b>Фильтры</b>
/vip слово — всегда срочно (имя препода, предмет)
/mute слово — никогда не присылать
/lists — показать списки, /unvip и /unmute — удалить
<b>Браузер</b>
/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 = ["<b>Состояние</b>"]
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<b>{src}</b> за сутки: " + ", ".join(f"{k}={v}" for k, v in d.items()))
lines.append(f"\nМодель: <code>{html.escape(self.s.model)}</code>, порог уведомлений: {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"<pre>{html.escape(json.dumps(d, ensure_ascii=False, indent=1)[:3800])}</pre>")
@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} <b>{html.escape((it.sender or '')[:40])}</b>: "
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"<b>VIP:</b> {html.escape(', '.join(vip) or '—')}\n<b>Mute:</b> {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)

0
app/browser/__init__.py Normal file
View File

178
app/browser/login.py Normal file
View File

@@ -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 и выбери число <b>{number}</b>")
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

213
app/browser/session.py Normal file
View File

@@ -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()

123
app/browser/watcher.js Normal file
View File

@@ -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) });
})();

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")),
)

158
app/db.py Normal file
View File

@@ -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()

124
app/llm.py Normal file
View File

@@ -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()

73
app/main.py Normal file
View File

@@ -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())

264
app/pipeline.py Normal file
View File

@@ -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, '')} <b>{SRC_NAME.get(it['source'], it['source'])}</b> · {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<blockquote>{e(orig)}</blockquote>"
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"🗞 <b>Дайджест</b> ({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<b>{SRC_NAME[src]}</b>")
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")

7
config/rules.md Normal file
View File

@@ -0,0 +1,7 @@
# Мои правила важности (читается LLM при каждой оценке — правь свободно, перезапуск не нужен)
- Всё от преподавателей и деканата — минимум «важно».
- Упоминания экзаменов, колоквиумов, зачётов, дедлайнов, переносов и отмен пар — «срочно», если это в ближайшие 2 дня.
- Сообщения в чатах моей учебной группы — «в дайджест», кроме случаев, когда меня упоминают или спрашивают лично.
- Рассылки студсовета, мероприятия, опросы — «в дайджест».
- Автоматические письма Moodle/USOS о новых оценках — «срочно».

19
config/selectors.json Normal file
View File

@@ -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
}
]
}

15
deploy/uni-notify.service Normal file
View File

@@ -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

13
docker-compose.yml Normal file
View File

@@ -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

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
playwright>=1.50,<2
aiogram>=3.13,<4
httpx>=0.27
python-dotenv>=1.0

0
tests/__init__.py Normal file
View File

113
tests/test_e2e.py Normal file
View File

@@ -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 = """<!doctype html><title>Mail - Outlook</title><body>
<div role="listbox" id="lb"></div>
<script>
const lb = document.getElementById('lb');
function add(id, txt){ const d=document.createElement('div'); d.setAttribute('role','option'); d.setAttribute('data-convid',id); d.innerText=txt; lb.prepend(d); }
add('c2','Uni Newsletter\\nWeekly newsletter number 42\\nnews news news news\\nMon');
add('c1','Old Sender\\nOld subject that existed before\\nold preview text here\\n09:12');
setTimeout(()=>{ add('c3','Prof. Kowalski\\nKolokwium przeniesione\\nKolokwium z analizy przeniesione na jutro 10:00, sala 204\\n14:05'); }, 5000);
setTimeout(()=>{ new Notification('Prof. Kowalski', {body:'Kolokwium z analizy przeniesione na jutro 10:00, sala 204'}); }, 5300);
setTimeout(()=>{ add('c4','Dziekanat WMI\\nStypendium\\nProsimy o uzupełnienie dokumentów do stypendium do piątku\\n14:07'); }, 7000);
setTimeout(()=>{ add('c5','Uni Newsletter\\nNewsletter number 43 is out\\nmore news and promotions for you\\n14:08'); }, 7500);
// время в карточке «тикает» — не должно давать новых событий
setTimeout(()=>{ lb.querySelector('[data-convid=c1]').innerText='Old Sender\\nOld subject that existed before\\nold preview text here\\n09:13'; }, 8000);
</script></body>"""
TEAMS_HTML = """<!doctype html><title>Chat | Microsoft Teams</title><body>
<div role="tree">
<div role="treeitem" id="grp">Recent
<div role="treeitem" data-item-id="t1">Group Projekt\\nAnia: ok see you tomorrow everyone\\n12:00</div>
</div>
</div>
<script>
setTimeout(()=>{ const t=document.querySelector('[data-item-id=t1]'); t.innerText='Group Projekt\\nAnia: @Me can you send the report by tonight?\\n14:10'; }, 6000);
</script></body>"""
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())