Files
uni-notify/app/browser/login.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

179 lines
7.7 KiB
Python
Raw 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.
"""Автоматический вход в 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