Skip to content
Open
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ app:
telegram:
# bot_token: <your Telegram bot token>
# chat_id: <your Telegram user or 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: <your Pushover user key>
# api_token: <your Pushover api token>
Expand Down
37 changes: 37 additions & 0 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
79 changes: 78 additions & 1 deletion src/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
118 changes: 115 additions & 3 deletions src/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
__author__ = "Mandar Patil <mandarons@pm.me>"
import datetime
import os
import re
from time import sleep

from icloudpy import ICloudPyService, exceptions, utils
Expand Down Expand Up @@ -384,14 +385,19 @@ 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.

Args:
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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading