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:
43
.env.example
Normal file
43
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
94
README.md
Normal 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
0
app/__init__.py
Normal file
274
app/bot.py
Normal file
274
app/bot.py
Normal 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
0
app/browser/__init__.py
Normal file
178
app/browser/login.py
Normal file
178
app/browser/login.py
Normal 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
213
app/browser/session.py
Normal 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
123
app/browser/watcher.js
Normal 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
104
app/config.py
Normal 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
158
app/db.py
Normal 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
124
app/llm.py
Normal 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
73
app/main.py
Normal 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
264
app/pipeline.py
Normal 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
7
config/rules.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Мои правила важности (читается LLM при каждой оценке — правь свободно, перезапуск не нужен)
|
||||
|
||||
- Всё от преподавателей и деканата — минимум «важно».
|
||||
- Упоминания экзаменов, колоквиумов, зачётов, дедлайнов, переносов и отмен пар — «срочно», если это в ближайшие 2 дня.
|
||||
- Сообщения в чатах моей учебной группы — «в дайджест», кроме случаев, когда меня упоминают или спрашивают лично.
|
||||
- Рассылки студсовета, мероприятия, опросы — «в дайджест».
|
||||
- Автоматические письма Moodle/USOS о новых оценках — «срочно».
|
||||
19
config/selectors.json
Normal file
19
config/selectors.json
Normal 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
15
deploy/uni-notify.service
Normal 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
13
docker-compose.yml
Normal 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
4
requirements.txt
Normal 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
0
tests/__init__.py
Normal file
113
tests/test_e2e.py
Normal file
113
tests/test_e2e.py
Normal 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())
|
||||
Reference in New Issue
Block a user