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:
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)
|
||||
Reference in New Issue
Block a user