Files
uni-notify/app/bot.py
Claude da28d0ac1c 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
2026-11-13 14:20:14 +01:00

275 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)