Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ docs/

# Local review artifacts (parallel-reviewer outputs)
reviews/

.tokensave/
8 changes: 7 additions & 1 deletion src/bot/handlers/captcha.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,13 @@ async def captcha_callback_handler(
# DB finalization first (reversible). If it fails, user stays restricted
# in Telegram, DB row preserved, timeout still armed — user can retry.
try:
db.remove_pending_captcha(target_user_id, group_config.group_id)
removed = db.remove_pending_captcha(target_user_id, group_config.group_id)
if not removed:
# Duplicate callback delivery (e.g. double-tap) raced us here —
# the other delivery already finalized this user. Ack and stop.
logger.info(f"Captcha for user {target_user_id} already finalized, ignoring duplicate callback")
await query.answer()
return
db.start_new_user_probation(target_user_id, group_config.group_id)
except Exception:
logger.error(f"DB finalization failed for user {target_user_id}", exc_info=True)
Expand Down
4 changes: 2 additions & 2 deletions src/bot/services/user_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def check_user_profile(bot: Bot, user: User) -> ProfileCheckResult:
ProfileCheckResult: Result containing photo and username status.
"""
logger.info(f"Checking profile for user_id={user.id}")

has_username = user.username is not None

db = get_database()
Expand All @@ -96,5 +96,5 @@ async def check_user_profile(bot: Bot, user: User) -> ProfileCheckResult:
logger.info(
f"Profile check for user_id={user.id}: has_photo={has_profile_photo}, has_username={has_username}"
)

return result
31 changes: 31 additions & 0 deletions tests/test_captcha.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,37 @@ async def test_db_finalization_failure_keeps_user_restricted(
# Telegram unrestrict was NOT called (DB failure short-circuits).
mock_unrestrict.assert_not_called()

async def test_duplicate_callback_delivery_ignored(
self, mock_context, mock_registry, temp_db
):
"""remove_pending_captcha returns False when another concurrent
delivery of the same double-tap already finalized this user.
The duplicate must ack quietly and never re-run unrestrict/probation."""
from bot.database.service import get_database

db = get_database()
db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User")

query = self._make_callback_query()

update = MagicMock()
update.callback_query = query

with (
patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry),
patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)),
patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict,
patch.object(db, "remove_pending_captcha", return_value=False),
patch.object(db, "start_new_user_probation") as mock_probation,
):
await captcha_callback_handler(update, mock_context)

# Bare ack so the client's spinner clears — no alert, no success edit.
query.answer.assert_called_once_with()
query.edit_message_text.assert_not_called()
mock_unrestrict.assert_not_called()
mock_probation.assert_not_called()

async def test_edit_message_failure_in_callback_continues_gracefully(
self, mock_context, mock_registry, temp_db
):
Expand Down
Loading