- 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
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""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)
|