Skip to content

Commit 92e537c

Browse files
committed
feat(connect): delete QR code message after connect flow completes
- Track QR photo message_id via unified sensitive_msg_ids list in _qr_login_tasks - Delete QR message in _poll_qr_login finally block (success, timeout, error, cancel) - Add sensitive_msg_ids param to _register_qr_login_task and _poll_qr_login - Simplify on_connect_cancel_callback: let background task finally handle cleanup - Add architectural comments explaining three connect-flow state dicts - Add tests: QR message deleted on success, timeout, and task cancellation - Update existing _poll_qr_login tests for new sensitive_msg_ids param - Update test_cancels_qr_task for new _qr_login_tasks dict structure
1 parent fcc1d3e commit 92e537c

File tree

3 files changed

+107
-25
lines changed

3 files changed

+107
-25
lines changed

handlers/connect_handler.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,22 @@
3535
from utils.telegram_user import upsert_effective_user
3636

3737

38-
# ====== /connect ======
39-
40-
_qr_login_tasks: dict[int, asyncio.Task] = {}
41-
42-
# Ожидающие 2FA-пароля: {user_id: {client, language_code, bot, chat_id}}
38+
# --- Состояние connect-flow ---
39+
# Три отдельных dict'а: QR-flow (фоновый polling + cleanup) и phone-flow (многошаговая
40+
# state-machine с таймаутами) имеют разную логику и жизненный цикл.
41+
# _pending_2fa — промежуточная фаза QR-flow: после сканирования QR потребовался 2FA-пароль,
42+
# polling-задача уже завершилась, но клиент остаётся живым для check_password.
43+
44+
# QR-flow: фоновая задача polling + ID сообщений для cleanup
45+
# {user_id: {"task": Task, "sensitive_msg_ids": list[int], "chat_id": int}}
46+
_qr_login_tasks: dict[int, dict] = {}
47+
48+
# QR-flow, фаза 2FA: клиент ожидает ввода пароля после успешного сканирования QR
49+
# {user_id: {"client": Client, "language_code": str, "bot": Bot, "chat_id": int}}
4350
_pending_2fa: dict[int, dict] = {}
4451

45-
# Ожидающие phone-логина: {user_id: {state, client, phone_number, phone_code_hash, expires_at, ...}}
46-
# state: "awaiting_phone" | "awaiting_code" | "awaiting_2fa"
52+
# Phone-flow: многошаговая state-machine (ввод номера → подтверждение → код → 2FA)
53+
# {user_id: {"state": str, "client": Client, ..., "sensitive_msg_ids": list[int], "expires_at": float}}
4754
_pending_phone: dict[int, dict] = {}
4855

4956

@@ -124,8 +131,11 @@ async def cancel_pending_phone(
124131

125132
def _get_qr_login_task(user_id: int) -> asyncio.Task | None:
126133
"""Возвращает активную QR-задачу пользователя."""
127-
task = _qr_login_tasks.get(user_id)
128-
if task and task.done():
134+
entry = _qr_login_tasks.get(user_id)
135+
if entry is None:
136+
return None
137+
task = entry["task"]
138+
if task.done():
129139
_qr_login_tasks.pop(user_id, None)
130140
return None
131141
return task
@@ -152,13 +162,18 @@ async def clear_pending_input(context: ContextTypes.DEFAULT_TYPE, user_id: int,
152162
await cancel_pending_phone(user_id, bot=bot)
153163

154164

155-
def _register_qr_login_task(user_id: int, task: asyncio.Task) -> None:
165+
def _register_qr_login_task(
166+
user_id: int, task: asyncio.Task,
167+
sensitive_msg_ids: list[int] | None = None, chat_id: int | None = None,
168+
) -> None:
156169
"""Регистрирует фоновую QR-задачу до её завершения."""
157-
_qr_login_tasks[user_id] = task
170+
_qr_login_tasks[user_id] = {
171+
"task": task, "sensitive_msg_ids": sensitive_msg_ids or [], "chat_id": chat_id,
172+
}
158173

159174
def _cleanup(done_task: asyncio.Task) -> None:
160-
current_task = _qr_login_tasks.get(user_id)
161-
if current_task is done_task:
175+
entry = _qr_login_tasks.get(user_id)
176+
if entry is not None and entry["task"] is done_task:
162177
_qr_login_tasks.pop(user_id, None)
163178

164179
task.add_done_callback(_cleanup)
@@ -290,16 +305,20 @@ async def _start_qr_flow(
290305
keyboard = InlineKeyboardMarkup([
291306
[InlineKeyboardButton(cancel_label, callback_data="connect:cancel")]
292307
])
293-
await bot.send_photo(chat_id=chat_id, photo=buf, caption=msg, reply_markup=keyboard)
308+
qr_sent = await bot.send_photo(chat_id=chat_id, photo=buf, caption=msg, reply_markup=keyboard)
309+
sensitive_msg_ids = []
310+
qr_mid = getattr(qr_sent, "message_id", None)
311+
if qr_mid is not None:
312+
sensitive_msg_ids.append(qr_mid)
294313

295314
if DEBUG_PRINT:
296315
print(f"{get_timestamp()} [CONNECT_QR] QR sent to user {user_id}, waiting for scan...")
297316

298317
# Запускаем polling в фоне
299318
task = asyncio.create_task(
300-
_poll_qr_login(client, user_id, language_code, bot, chat_id)
319+
_poll_qr_login(client, user_id, language_code, bot, chat_id, sensitive_msg_ids=sensitive_msg_ids)
301320
)
302-
_register_qr_login_task(user_id, task)
321+
_register_qr_login_task(user_id, task, sensitive_msg_ids=sensitive_msg_ids, chat_id=chat_id)
303322
client = None
304323

305324
except Exception as e:
@@ -584,7 +603,7 @@ async def on_connect_cancel_callback(update: Update, context: ContextTypes.DEFAU
584603
u = update.effective_user
585604
language_code = u.language_code
586605

587-
# Отменяем QR-flow
606+
# Отменяем QR-flow; cleanup выполнит сама фоновая задача в finally
588607
qr_task = _get_qr_login_task(u.id)
589608
if qr_task is not None:
590609
qr_task.cancel()
@@ -779,7 +798,10 @@ async def _finalize_phone_login(
779798
await _delete_sensitive_messages(bot, chat_id, sensitive_msg_ids)
780799

781800

782-
async def _poll_qr_login(client: Client, user_id: int, language_code: str, bot: Bot, chat_id: int) -> None:
801+
async def _poll_qr_login(
802+
client: Client, user_id: int, language_code: str, bot: Bot, chat_id: int,
803+
sensitive_msg_ids: list[int] | None = None,
804+
) -> None:
783805
"""Фоновая задача: ожидает сканирования QR-кода (до 2 минут)."""
784806
try:
785807
authorized = False
@@ -910,6 +932,9 @@ async def _poll_qr_login(client: Client, user_id: int, language_code: str, bot:
910932
# Не отключаем клиент, если он передан в _pending_2fa (ожидает 2FA-пароль)
911933
if user_id not in _pending_2fa:
912934
await _safe_disconnect_temp_client(client, user_id)
935+
# Удаляем чувствительные сообщения QR-flow (QR-фото и др.)
936+
if sensitive_msg_ids:
937+
await _delete_sensitive_messages(bot, chat_id, sensitive_msg_ids)
913938

914939

915940
async def handle_2fa_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:

tests/test_connect_flow.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,11 +387,13 @@ async def test_cancels_phone_flow(self):
387387

388388
@pytest.mark.asyncio
389389
async def test_cancels_qr_task(self):
390-
"""Отмена QR-flow → отменяет задачу."""
390+
"""Отмена QR-flow → отменяет задачу и убирает её из реестра."""
391391
user_id = 123456
392392
mock_task = MagicMock()
393393
mock_task.done.return_value = False
394-
_qr_login_tasks[user_id] = mock_task
394+
_qr_login_tasks[user_id] = {
395+
"task": mock_task, "sensitive_msg_ids": [500], "chat_id": user_id,
396+
}
395397

396398
update = _make_callback_update(user_id=user_id)
397399
context = _make_context()
@@ -401,6 +403,7 @@ async def test_cancels_qr_task(self):
401403

402404
mock_task.cancel.assert_called_once()
403405
assert user_id not in _qr_login_tasks
406+
context.bot.delete_message.assert_not_called()
404407

405408
@pytest.mark.asyncio
406409
async def test_cancels_2fa_flow(self):

tests/test_handlers.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# tests/test_handlers.py — Тесты для handlers/pyrogram_handlers.py
22

3+
import asyncio
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
@@ -36,6 +37,7 @@
3637

3738
from config import DEFAULT_STYLE, STYLE_TO_EMOJI
3839
TYPING_TEXT = SYSTEM_MESSAGES["draft_typing"].format(emoji=STYLE_TO_EMOJI[DEFAULT_STYLE])
40+
REAL_ASYNCIO_SLEEP = asyncio.sleep
3941

4042

4143
def _close_coroutine_task(coro):
@@ -337,13 +339,15 @@ async def test_poll_qr_login_success_saves_session_and_starts_listening(self, mo
337339
patch("handlers.connect_handler.get_system_message", new_callable=AsyncMock, return_value="Connected"):
338340
mock_pc.start_listening = AsyncMock(return_value=True)
339341

340-
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456)
342+
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
341343

342344
mock_client.export_session_string.assert_called_once()
343345
mock_client.disconnect.assert_called_once()
344346
mock_save_session.assert_called_once_with(123, "session-123")
345347
mock_pc.start_listening.assert_called_once_with(123, "session-123")
346348
mock_bot.send_message.assert_called_once_with(chat_id=456, text="Connected")
349+
# QR-сообщение удалено
350+
mock_bot.delete_message.assert_called_once_with(chat_id=456, message_id=500)
347351

348352
@pytest.mark.asyncio
349353
async def test_poll_qr_login_stops_when_save_session_fails(self, mock_bot):
@@ -362,7 +366,7 @@ async def test_poll_qr_login_stops_when_save_session_fails(self, mock_bot):
362366
patch("handlers.connect_handler.get_system_message", new_callable=AsyncMock, return_value="Connect failed"):
363367
mock_pc.start_listening = AsyncMock(return_value=True)
364368

365-
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456)
369+
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
366370

367371
mock_pc.start_listening.assert_not_called()
368372
mock_bot.send_message.assert_called_once_with(chat_id=456, text="Connect failed")
@@ -385,7 +389,7 @@ async def test_poll_qr_login_clears_session_when_listener_start_fails(self, mock
385389
patch("handlers.connect_handler.get_system_message", new_callable=AsyncMock, return_value="Connect failed"):
386390
mock_pc.start_listening = AsyncMock(return_value=False)
387391

388-
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456)
392+
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
389393

390394
mock_clear.assert_called_once_with(123)
391395
mock_bot.send_message.assert_called_once_with(chat_id=456, text="Connect failed")
@@ -409,7 +413,7 @@ async def test_poll_qr_login_stores_user_from_success_result(self, mock_bot):
409413
patch("handlers.connect_handler.get_system_message", new_callable=AsyncMock, return_value="OK"):
410414
mock_pc.start_listening = AsyncMock(return_value=True)
411415

412-
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456)
416+
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
413417

414418
mock_client.storage.user_id.assert_called_with(777)
415419
mock_client.storage.is_bot.assert_called_with(False)
@@ -437,17 +441,67 @@ async def test_poll_qr_migration_2fa_enters_pending_2fa(self, mock_bot):
437441
mock_auth_cls.return_value.create = AsyncMock(return_value=b"new_key")
438442
mock_pyro_session.return_value = AsyncMock()
439443

440-
await _poll_qr_login(mock_client, 42, "en", mock_bot, 456)
444+
await _poll_qr_login(mock_client, 42, "en", mock_bot, 456, sensitive_msg_ids=[500])
441445

442446
# Клиент сохранён в _pending_2fa (не отключен)
443447
assert 42 in _pending_2fa
444448
assert _pending_2fa[42]["client"] is mock_client
445449
mock_bot.send_message.assert_called_once_with(chat_id=456, text="Enter 2FA password")
446450
mock_client.disconnect.assert_not_called()
451+
# QR-сообщение удалено даже при переходе в 2FA
452+
mock_bot.delete_message.assert_called_once_with(chat_id=456, message_id=500)
447453

448454
# Cleanup
449455
_pending_2fa.pop(42, None)
450456

457+
@pytest.mark.asyncio
458+
async def test_poll_qr_login_deletes_qr_message_on_timeout(self, mock_bot):
459+
"""QR-сообщение удаляется при таймауте."""
460+
# Все poll-ы возвращают LoginToken (не авторизован)
461+
login_token = type("LoginToken", (), {"token": b"tok"})()
462+
463+
mock_client = AsyncMock()
464+
mock_client.invoke = AsyncMock(return_value=login_token)
465+
mock_client.disconnect = AsyncMock()
466+
467+
with patch("handlers.connect_handler.asyncio.sleep", new_callable=AsyncMock), \
468+
patch("handlers.connect_handler.get_system_message", new_callable=AsyncMock, return_value="QR expired"):
469+
470+
await _poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
471+
472+
# QR-сообщение удалено при таймауте
473+
mock_bot.delete_message.assert_called_once_with(chat_id=456, message_id=500)
474+
475+
@pytest.mark.asyncio
476+
async def test_poll_qr_login_deletes_qr_message_on_task_cancel(self, mock_bot):
477+
"""При отмене фоновой QR-задачи cleanup удаляет QR-сообщение ровно один раз."""
478+
login_token = type("LoginToken", (), {"token": b"tok"})()
479+
480+
mock_client = AsyncMock()
481+
mock_client.invoke = AsyncMock(return_value=login_token)
482+
mock_client.disconnect = AsyncMock()
483+
484+
sleep_started = asyncio.Event()
485+
release_sleep = asyncio.Event()
486+
487+
async def blocked_sleep(_: int) -> None:
488+
sleep_started.set()
489+
await release_sleep.wait()
490+
491+
with patch("handlers.connect_handler.asyncio.sleep", side_effect=blocked_sleep):
492+
task = asyncio.create_task(
493+
_poll_qr_login(mock_client, 123, "en", mock_bot, 456, sensitive_msg_ids=[500])
494+
)
495+
await sleep_started.wait()
496+
await REAL_ASYNCIO_SLEEP(0)
497+
task.cancel()
498+
499+
with pytest.raises(asyncio.CancelledError):
500+
await task
501+
502+
mock_client.disconnect.assert_called_once()
503+
mock_bot.delete_message.assert_called_once_with(chat_id=456, message_id=500)
504+
451505

452506
class TestOnPyrogramMessage:
453507
"""Тесты для on_pyrogram_message()."""

0 commit comments

Comments
 (0)