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

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