"""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 = """uni-notify — слежу за почтой и Teams, пишу о важном. Основное /status — состояние сессий и статистика /recent [N] — последние важные события /digest — дайджест неважного прямо сейчас /login — войти заново (спрошу 2FA-код) Просто напиши вопрос — отвечу по журналу: «что препод писал про курсач?» Фильтры /vip слово — всегда срочно (имя препода, предмет) /mute слово — никогда не присылать /lists — показать списки, /unvip и /unmute — удалить Браузер /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 = ["Состояние"] 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{src} за сутки: " + ", ".join(f"{k}={v}" for k, v in d.items())) lines.append(f"\nМодель: {html.escape(self.s.model)}, порог уведомлений: {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"
{html.escape(json.dumps(d, ensure_ascii=False, indent=1)[:3800])}
") @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} {html.escape((it.sender or '')[:40])}: " 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"VIP: {html.escape(', '.join(vip) or '—')}\nMute: {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)