diff --git a/README.md b/README.md index 0e13cc193..df15cdab0 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,28 @@ Automatic notifications are sent when your iCloud authentication expires and 2FA - **Multi-Channel**: Sent to all configured notification services simultaneously - **Critical Priority**: Ensures you're promptly notified when manual authentication is needed +#### Re-authenticate over Telegram (headless 2FA) + +If you run headless (no console, no web UI), you can complete 2FA straight from +Telegram. Set `app.telegram.bot_token` + `chat_id`, then enable `app.telegram.listen: true`. +When iCloud needs re-authentication the container messages your chat and waits: + +1. Reply the **auth keyword** (default `auth`, set via `app.telegram.auth_keyword`). + The container calls Apple to push a 2FA code to your trusted devices. +2. Reply the **6-digit code** Apple sent (spaces/dashes are tolerated, so `123 456` + works). The container validates it and trusts the session, and sync resumes — no + console or web UI needed. + +Only messages from your configured `chat_id` are honoured. Other configured channels +(email, Discord, Pushover) still receive the standard 2FA alert. + +**Notes:** +- **Multiple containers:** give each its own **bot token** (a `getUpdates` poll consumes + updates for the whole bot, so containers sharing one token would steal each other's + replies). A distinct `auth_keyword` per container is still useful for clarity. +- **Use a 1:1 chat with the bot.** In group chats, Telegram bot privacy mode hides plain + `auth`/code messages from the bot. + ### Sync Summary Notifications Get detailed reports after each sync cycle with comprehensive statistics: diff --git a/config.yaml b/config.yaml index 8a3c0e40d..0f1ce16c2 100644 --- a/config.yaml +++ b/config.yaml @@ -34,6 +34,13 @@ app: telegram: # bot_token: # chat_id: + # listen: true # also poll Telegram for replies so you can re-auth 2FA from your phone: + # # reply the auth keyword to have a code pushed to your Apple devices, + # # then reply the 6-digit code. Off by default. Requires bot_token + chat_id. + # # Use a 1:1 chat with the bot (group privacy mode hides plain messages). + # auth_keyword: auth # reply word that triggers the 2FA push (default "auth", case-insensitive). + # # Running multiple containers? Give each its own bot token (a getUpdates + # # poll consumes the whole bot's updates), plus a distinct auth_keyword. pushover: # user_key: # api_token: diff --git a/src/config_parser.py b/src/config_parser.py index 4099b0766..cead13b7d 100644 --- a/src/config_parser.py +++ b/src/config_parser.py @@ -878,6 +878,42 @@ def get_telegram_chat_id(config: dict) -> str | None: return get_notification_config_value(config, "telegram", "chat_id") +def get_telegram_listen_enabled(config: dict) -> bool: + """Whether to poll Telegram for inbound replies during a 2FA wait. + + Opt-in (default False). When True, the 2FA wait gap is replaced with a + Telegram ``getUpdates`` poll: the user replies the auth keyword to have a + code pushed to their Apple devices, then replies the 6-digit code -- so + re-authentication can be completed from a phone, headless. + + Reuses ``app.telegram.bot_token`` / ``chat_id`` from the existing outbound + config; ``chat_id`` filtering means only messages from the user's own chat + are honoured (no separate auth surface). + """ + return bool( + get_config_value_or_none( + config=config, + config_path=["app", "telegram", "listen"], + ), + ) + + +def get_telegram_auth_keyword(config: dict) -> str: + """The reply keyword that triggers a 2FA push (default ``auth``). + + Customisable via ``app.telegram.auth_keyword`` so users running multiple + containers in one chat can target a specific one (e.g. ``auth-photos``). + Matched case-insensitively; returns the lowercased word. + """ + # Optional key with a sane default -- quiet lookup so the absent case does + # not log a "not found" warning on every 2FA wait. + value = get_config_value_or_none( + config=config, + config_path=["app", "telegram", "auth_keyword"], + ) + return str(value).strip().lower() if value else "auth" + + def get_discord_webhook_url(config: dict) -> str | None: """Return discord webhook_url from config. @@ -925,6 +961,7 @@ def get_pushover_api_token(config: dict) -> str | None: """ return get_notification_config_value(config, "pushover", "api_token") + def get_pushover_notification_priority(config: dict) -> int | None: """Return Pushover notification priority from config. diff --git a/src/notify.py b/src/notify.py index 62cd27ef8..eb1c13783 100644 --- a/src/notify.py +++ b/src/notify.py @@ -50,6 +50,21 @@ def _create_2fa_message(username: str, region: str = "global") -> tuple[str, str return message, subject +def _create_telegram_reply_prompt(config) -> str: + """Build the actionable Telegram reply-2FA prompt (used when listen is on). + + Replaces the generic "run docker exec" instructions with steps the user can + complete from their phone: reply the auth keyword to request a code, then + reply the 6-digit code. + """ + auth_keyword = config_parser.get_telegram_auth_keyword(config=config) + return ( + "\U0001f510 iCloud needs re-authentication. Reply " + f"'{auth_keyword}' and a 2FA code will be sent to your Apple devices; " + "then reply the 6-digit code here." + ) + + def _get_current_timestamp() -> datetime.datetime: """ Get the current timestamp for notification tracking. @@ -130,6 +145,61 @@ def post_message_to_telegram(bot_token: str, chat_id: str, message: str) -> bool return False +def poll_telegram_for_text( + bot_token: str, + chat_id: str, + offset: int = 0, + request_timeout: int = 10, +) -> tuple[str | None, int]: + """Poll Telegram ``getUpdates`` for the next text message from ``chat_id``. + + Returns ``(text, new_offset)`` where ``text`` is the first stripped text + message from the user's chat (the caller classifies it as an auth keyword + vs a 6-digit code) and ``new_offset`` is that message's ``update_id`` so the + next poll continues to the following message -- important when a keyword and + the code arrive close together. Non-text / foreign-chat updates are skipped + and still advance the offset past them. Network / API failures are logged + and swallowed, returning ``(None, offset)`` so the caller keeps trying. + """ + url = f"https://api.telegram.org/bot{bot_token}/getUpdates" + params = { + "offset": str(offset + 1), + "allowed_updates": '["message"]', + "timeout": "0", + } + try: + response = requests.post(url, params=params, timeout=request_timeout) + except (requests.RequestException, OSError) as e: + LOGGER.warning(f"telegram getUpdates failed: {e!s}") + return None, offset + if response.status_code != 200: + LOGGER.warning( + f"telegram getUpdates returned {response.status_code}: {response.text[:200]}", + ) + return None, offset + try: + payload = response.json() + except ValueError as e: + LOGGER.warning(f"telegram getUpdates: malformed JSON: {e!s}") + return None, offset + updates = payload.get("result") or [] + new_offset = offset + for update in updates: + update_id = update.get("update_id") + message = update.get("message") or {} + chat = message.get("chat") or {} + if str(chat.get("id")) != str(chat_id): + if isinstance(update_id, int) and update_id > new_offset: + new_offset = update_id + continue + text = (message.get("text") or "").strip() + if text: + return text, (update_id if isinstance(update_id, int) else new_offset) + if isinstance(update_id, int) and update_id > new_offset: + new_offset = update_id + return None, new_offset + + def _get_discord_config(config) -> tuple[str | None, str | None, bool]: """ Extract Discord configuration from config. @@ -329,8 +399,15 @@ def send(config, username, last_send=None, dry_run=False, region="global"): """ message, subject = _create_2fa_message(username, region) + # When Telegram reply-2FA is enabled, send the actionable reply prompt to + # Telegram instead of the generic "run docker exec" message; the other + # channels still get the standard alert. + telegram_message = message + if config_parser.get_telegram_listen_enabled(config=config): + telegram_message = _create_telegram_reply_prompt(config) + # Send to all notification services - telegram_sent = notify_telegram(config=config, message=message, last_send=last_send, dry_run=dry_run) + telegram_sent = notify_telegram(config=config, message=telegram_message, last_send=last_send, dry_run=dry_run) discord_sent = notify_discord(config=config, message=message, last_send=last_send, dry_run=dry_run) pushover_sent = notify_pushover(config=config, message=message, last_send=last_send, dry_run=dry_run) email_sent = notify_email(config=config, message=message, subject=subject, last_send=last_send, dry_run=dry_run) diff --git a/src/sync.py b/src/sync.py index 3dec0ce37..6884d2c0c 100644 --- a/src/sync.py +++ b/src/sync.py @@ -3,6 +3,7 @@ __author__ = "Mandar Patil " import datetime import os +import re from time import sleep from icloudpy import ICloudPyService, exceptions, utils @@ -384,7 +385,7 @@ def _send_usage_statistics(config, summary: SyncSummary) -> None: alive(config=config, data=usage_data) -def _handle_2fa_required(config, username: str, sync_state: SyncState): +def _handle_2fa_required(config, username: str, sync_state: SyncState, api=None): """ Handle 2FA authentication requirement. @@ -392,6 +393,11 @@ def _handle_2fa_required(config, username: str, sync_state: SyncState): config: Configuration dictionary username: iCloud username sync_state: Current sync state + api: Live ``ICloudPyService`` instance still in its 2FA-required state. + When provided AND ``app.telegram.listen`` is true, the retry sleep is + replaced with a Telegram poll: the user replies the auth keyword to + have Apple push a code to their devices, then replies the 6 digits -- + completing re-authentication from a phone, headless, with no web UI. Returns: bool: True if should continue (retry), False if should exit @@ -405,16 +411,122 @@ def _handle_2fa_required(config, username: str, sync_state: SyncState): _log_retry_time(sleep_for) server_region = config_parser.get_region(config=config) + # notify.send is throttled (once per 24h) and listen-aware: in listen mode the + # Telegram channel gets the actionable reply prompt, other channels the standard + # alert. Throttling here is what prevents a "reply auth" message every retry cycle. sync_state.last_send = notify.send( config=config, username=username, last_send=sync_state.last_send, region=server_region, ) - sleep(sleep_for) + if api is not None and config_parser.get_telegram_listen_enabled(config=config): + _wait_for_telegram_code(config=config, api=api, timeout_seconds=sleep_for) + else: + sleep(sleep_for) return True +def _wait_for_telegram_code(config, api, timeout_seconds: int) -> bool: + """Drive 2FA over Telegram with a manual, user-initiated trigger. + + The reply prompt itself is sent by ``notify.send`` (throttled); this function + drains any stale replies, then polls for the user's actions: + 1. On the auth keyword -> ``api.trigger_2fa_push_notification()`` so Apple + actually pushes a code to the trusted devices. (The headless path + previously waited for a code it never requested -- this missing trigger + is the core bug this fixes.) + 2. On a 6-digit reply (spaces/dashes tolerated) -> ``validate_2fa_code`` + + ``trust_session``. + + Returns True once a code validates and trust succeeds within + ``timeout_seconds``; False on timeout. Best-effort throughout. The Telegram + ``getUpdates`` offset is held in-memory for the duration of this wait. + """ + poll_interval = 5 + bot_token = config_parser.get_telegram_bot_token(config=config) + chat_id = config_parser.get_telegram_chat_id(config=config) + auth_keyword = config_parser.get_telegram_auth_keyword(config=config) + if not bot_token or not chat_id: + LOGGER.warning( + "Telegram listen enabled but bot_token/chat_id not configured; falling back to plain sleep.", + ) + sleep(timeout_seconds) + return False + + # Drain any messages already pending so a stale reply from a previous + # session does not get acted on; start listening for genuinely new replies. + _, offset = notify.poll_telegram_for_text(bot_token=bot_token, chat_id=chat_id, offset=0) + while True: + _, drained = notify.poll_telegram_for_text(bot_token=bot_token, chat_id=chat_id, offset=offset) + if drained == offset: + break + offset = drained + + LOGGER.info( + f"Listening on Telegram for '{auth_keyword}' trigger or 6-digit code (timeout {timeout_seconds}s).", + ) + elapsed = 0 + while elapsed < timeout_seconds: + chunk = min(poll_interval, timeout_seconds - elapsed) + sleep(chunk) + elapsed += chunk + text, offset = notify.poll_telegram_for_text( + bot_token=bot_token, + chat_id=chat_id, + offset=offset, + ) + if not text: + continue + norm = text.strip().lower() + if norm == auth_keyword: + LOGGER.info("Telegram auth trigger received -- requesting 2FA push.") + try: + pushed = api.trigger_2fa_push_notification() + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"trigger_2fa_push_notification raised: {e!s}") + pushed = False + notify.post_message_to_telegram( + bot_token, + chat_id, + ( + "✅ 2FA code sent to your Apple devices -- reply the 6-digit code here." + if pushed + else "⚠️ Couldn't request a code (no trusted device, or auth state off). Try again shortly." + ), + ) + continue + code = norm.replace(" ", "").replace("-", "") + if re.fullmatch(r"\d{6}", code): + LOGGER.info("Received 6-digit code via Telegram -- validating.") + try: + accepted = api.validate_2fa_code(code) + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"validate_2fa_code raised: {e!s} -- waiting for another code.") + continue + if not accepted: + notify.post_message_to_telegram( + bot_token, + chat_id, + "❌ Apple rejected that code -- reply a fresh one.", + ) + LOGGER.warning("Apple rejected the Telegram-supplied code -- waiting for another.") + continue + try: + api.trust_session() + except Exception as e: # noqa: BLE001 + LOGGER.warning(f"trust_session raised (non-fatal): {e!s}") + notify.post_message_to_telegram( + bot_token, + chat_id, + "✅ Re-authenticated. iCloud sync resumed.", + ) + LOGGER.info("Telegram-driven 2FA succeeded; resuming sync.") + return True + LOGGER.info("Telegram listen timeout reached with no usable code; retrying auth.") + return False + + def _handle_password_error(config, username: str, sync_state: SyncState): """ Handle password not available error. @@ -622,7 +734,7 @@ def sync(): if not _check_services_configured(config): LOGGER.warning("Nothing to sync. Please add drive: and/or photos: section in config.yaml file.") else: - if not _handle_2fa_required(config, username, sync_state): + if not _handle_2fa_required(config, username, sync_state, api=api): break continue diff --git a/tests/test_telegram_2fa.py b/tests/test_telegram_2fa.py new file mode 100644 index 000000000..628a41aa2 --- /dev/null +++ b/tests/test_telegram_2fa.py @@ -0,0 +1,287 @@ +"""Tests for headless 2FA-over-Telegram: poller, config getters, notify routing, wait loop.""" + +import unittest +from unittest.mock import MagicMock, patch + +import requests + +from src import config_parser, notify, sync +from src.sync import SyncState + + +class _Resp: + """Minimal stand-in for a ``requests`` Response.""" + + def __init__(self, status_code=200, payload=None, text="", raise_json=False): + self.status_code = status_code + self._payload = payload if payload is not None else {"result": []} + self.text = text + self._raise_json = raise_json + + def json(self): + if self._raise_json: + msg = "bad json" + raise ValueError(msg) + return self._payload + + +class TestPollTelegramForText(unittest.TestCase): + """notify.poll_telegram_for_text -- getUpdates polling.""" + + @patch("src.notify.requests.post", side_effect=requests.RequestException("boom")) + def test_network_error_returns_offset_unchanged(self, _post): + """Network failure is swallowed; offset preserved.""" + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=5), (None, 5)) + + @patch("src.notify.requests.post", return_value=_Resp(status_code=500, text="err")) + def test_non_200_returns_offset_unchanged(self, _post): + """A non-200 response is logged and swallowed.""" + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=5), (None, 5)) + + @patch("src.notify.requests.post", return_value=_Resp(raise_json=True)) + def test_malformed_json_returns_offset_unchanged(self, _post): + """Malformed JSON is logged and swallowed.""" + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=5), (None, 5)) + + @patch("src.notify.requests.post", return_value=_Resp(payload={"result": []})) + def test_no_updates(self, _post): + """Empty result set returns no text.""" + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=5), (None, 5)) + + @patch("src.notify.requests.post") + def test_foreign_chat_skipped_then_our_text(self, post): + """Foreign-chat updates advance the offset; our text is returned.""" + post.return_value = _Resp( + payload={ + "result": [ + { + "update_id": 7, + "message": {"chat": {"id": "other"}, "text": "nope"}, + }, + { + "update_id": 8, + "message": {"chat": {"id": "c"}, "text": " hello "}, + }, + ], + }, + ) + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=0), ("hello", 8)) + + @patch("src.notify.requests.post") + def test_our_text_without_int_update_id_falls_back_to_offset(self, post): + """A text message lacking an int update_id returns the running offset.""" + post.return_value = _Resp( + payload={"result": [{"message": {"chat": {"id": "c"}, "text": "hi"}}]}, + ) + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=4), ("hi", 4)) + + @patch("src.notify.requests.post") + def test_our_nontext_message_advances_offset(self, post): + """A non-text message from our chat advances the offset and yields no text.""" + post.return_value = _Resp( + payload={"result": [{"update_id": 9, "message": {"chat": {"id": "c"}, "text": ""}}]}, + ) + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=2), (None, 9)) + + @patch("src.notify.requests.post") + def test_foreign_chat_without_int_update_id(self, post): + """A foreign-chat update with no int update_id is skipped without advancing.""" + post.return_value = _Resp( + payload={"result": [{"update_id": None, "message": {"chat": {"id": "other"}}}]}, + ) + self.assertEqual(notify.poll_telegram_for_text("t", "c", offset=3), (None, 3)) + + +class TestTelegramConfigGetters(unittest.TestCase): + """config_parser telegram listen / auth_keyword getters.""" + + def test_listen_enabled_true(self): + config = {"app": {"telegram": {"listen": True}}} + self.assertTrue(config_parser.get_telegram_listen_enabled(config=config)) + + def test_listen_disabled_by_default(self): + config = {"app": {"telegram": {}}} + self.assertFalse(config_parser.get_telegram_listen_enabled(config=config)) + + def test_auth_keyword_default(self): + config = {"app": {"telegram": {}}} + self.assertEqual(config_parser.get_telegram_auth_keyword(config=config), "auth") + + def test_auth_keyword_custom_is_normalised(self): + config = {"app": {"telegram": {"auth_keyword": " Auth-Photos "}}} + self.assertEqual(config_parser.get_telegram_auth_keyword(config=config), "auth-photos") + + +class TestNotifySendListenAware(unittest.TestCase): + """notify.send routes the reply prompt to Telegram only when listen is on.""" + + def test_reply_prompt_contains_keyword(self): + config = {"app": {"telegram": {"auth_keyword": "go-photos"}}} + prompt = notify._create_telegram_reply_prompt(config) # noqa: SLF001 + self.assertIn("go-photos", prompt) + self.assertIn("Reply", prompt) + + @patch("src.notify.notify_email", return_value=None) + @patch("src.notify.notify_pushover", return_value=None) + @patch("src.notify.notify_discord", return_value=None) + @patch("src.notify.notify_telegram", return_value=None) + def test_listen_on_sends_prompt_to_telegram_standard_to_others(self, tg, discord, _push, _email): + """Listen on: Telegram gets the reply prompt; other channels get the standard alert.""" + config = {"app": {"telegram": {"listen": True, "auth_keyword": "auth"}}} + notify.send(config=config, username="me@x.com") + tg_msg = tg.call_args.kwargs["message"] + self.assertIn("Reply", tg_msg) + self.assertNotIn("docker exec", tg_msg) + self.assertIn("docker exec", discord.call_args.kwargs["message"]) + + @patch("src.notify.notify_email", return_value=None) + @patch("src.notify.notify_pushover", return_value=None) + @patch("src.notify.notify_discord", return_value=None) + @patch("src.notify.notify_telegram", return_value=None) + def test_listen_off_sends_standard_to_telegram(self, tg, _discord, _push, _email): + """Listen off: Telegram gets the standard 'run docker exec' message (unchanged).""" + config = {"app": {"telegram": {}}} + notify.send(config=config, username="me@x.com") + self.assertIn("docker exec", tg.call_args.kwargs["message"]) + + +class TestWaitForTelegramCode(unittest.TestCase): + """sync._wait_for_telegram_code -- the manual-trigger 2FA poll loop.""" + + def setUp(self): + self.config = {"app": {"telegram": {"bot_token": "t", "chat_id": "c"}}} + self.api = MagicMock() + + @patch("src.sync.sleep") + def test_missing_credentials_falls_back_to_sleep(self, mock_sleep): + """Without bot_token/chat_id the loop just sleeps and reports failure.""" + result = sync._wait_for_telegram_code(config={"app": {"telegram": {}}}, api=self.api, timeout_seconds=42) # noqa: SLF001 + self.assertFalse(result) + mock_sleep.assert_called_once_with(42) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_auth_keyword_triggers_push(self, _sleep, poll, post): + """Replying the keyword requests a 2FA push and confirms success.""" + self.api.trigger_2fa_push_notification.return_value = True + poll.side_effect = [(None, 0), (None, 0), ("auth", 5)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertFalse(result) # push sent, but no code arrived before timeout + self.api.trigger_2fa_push_notification.assert_called_once() + self.assertTrue(any("code sent" in c.args[2] for c in post.call_args_list)) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_auth_keyword_push_failure_is_reported(self, _sleep, poll, post): + """A failed push trigger tells the user, non-fatally.""" + self.api.trigger_2fa_push_notification.side_effect = RuntimeError("no device") + poll.side_effect = [(None, 0), (None, 0), ("AUTH", 5)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertFalse(result) + self.assertTrue(any("Couldn't request" in c.args[2] for c in post.call_args_list)) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_valid_code_validates_and_trusts(self, _sleep, poll, post): + """A 6-digit reply validates, trusts the session, and returns True.""" + self.api.validate_2fa_code.return_value = True + # drain advances the offset (0 -> 5), then a blank, then the code arrives + poll.side_effect = [(None, 0), (None, 5), (None, 5), (None, 5), ("123456", 6)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=10) # noqa: SLF001 + self.assertTrue(result) + self.api.validate_2fa_code.assert_called_once_with("123456") + self.api.trust_session.assert_called_once() + self.assertTrue(any("Re-authenticated" in c.args[2] for c in post.call_args_list)) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_code_with_spaces_is_accepted(self, _sleep, poll, _post): + """A code pasted as '123 456' is normalised and validated.""" + self.api.validate_2fa_code.return_value = True + poll.side_effect = [(None, 0), (None, 0), ("123 456", 6)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertTrue(result) + self.api.validate_2fa_code.assert_called_once_with("123456") + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_rejected_code_asks_for_another(self, _sleep, poll, post): + """Apple rejecting the code prompts for a fresh one.""" + self.api.validate_2fa_code.return_value = False + poll.side_effect = [(None, 0), (None, 0), ("123456", 6)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertFalse(result) + self.assertTrue(any("rejected" in c.args[2] for c in post.call_args_list)) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_validate_raises_keeps_waiting(self, _sleep, poll, _post): + """An exception from validate_2fa_code is swallowed; the loop keeps waiting.""" + self.api.validate_2fa_code.side_effect = RuntimeError("api down") + poll.side_effect = [(None, 0), (None, 0), ("123456", 6)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertFalse(result) + self.api.trust_session.assert_not_called() + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_trust_session_failure_is_non_fatal(self, _sleep, poll, post): + """trust_session raising still counts as a successful re-auth.""" + self.api.validate_2fa_code.return_value = True + self.api.trust_session.side_effect = RuntimeError("trust glitch") + poll.side_effect = [(None, 0), (None, 0), ("123456", 6)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertTrue(result) + self.assertTrue(any("Re-authenticated" in c.args[2] for c in post.call_args_list)) + + @patch("src.notify.post_message_to_telegram") + @patch("src.notify.poll_telegram_for_text") + @patch("src.sync.sleep") + def test_unrelated_text_is_ignored_until_timeout(self, _sleep, poll, _post): + """Replies that are neither keyword nor 6-digit are ignored.""" + poll.side_effect = [(None, 0), (None, 0), ("hello there", 5)] + result = sync._wait_for_telegram_code(config=self.config, api=self.api, timeout_seconds=5) # noqa: SLF001 + self.assertFalse(result) + self.api.trigger_2fa_push_notification.assert_not_called() + + +class TestHandle2faRequiredTelegramBranch(unittest.TestCase): + """sync._handle_2fa_required -- telegram branch wiring.""" + + @patch("src.sync.notify.send", return_value=None) + @patch("src.config_parser.get_retry_login_interval", return_value=-1) + def test_negative_interval_exits(self, _interval, _send): + """A negative retry interval signals exit (return False).""" + self.assertFalse(sync._handle_2fa_required({}, "user", SyncState(), api=MagicMock())) # noqa: SLF001 + + @patch("src.sync._wait_for_telegram_code") # noqa: SLF001 + @patch("src.sync.notify.send", return_value=None) + @patch("src.config_parser.get_retry_login_interval", return_value=60) + def test_listen_enabled_uses_telegram_wait(self, _interval, mock_send, mock_wait): + """With api + listen enabled, notify.send fires (throttled prompt) then the wait runs.""" + config = {"app": {"telegram": {"bot_token": "t", "chat_id": "c", "listen": True}}} + result = sync._handle_2fa_required(config, "user", SyncState(), api=MagicMock()) # noqa: SLF001 + self.assertTrue(result) + mock_send.assert_called_once() + mock_wait.assert_called_once() + + @patch("src.sync.sleep") + @patch("src.sync.notify.send", return_value=None) + @patch("src.config_parser.get_retry_login_interval", return_value=60) + def test_without_api_falls_back_to_sleep(self, _interval, mock_send, mock_sleep): + """No api (or listen off) keeps the original sleep + standard notification.""" + result = sync._handle_2fa_required({"app": {"telegram": {}}}, "user", SyncState(), api=None) # noqa: SLF001 + self.assertTrue(result) + mock_sleep.assert_called_once_with(60) + mock_send.assert_called_once() + + +if __name__ == "__main__": + unittest.main()