diff --git a/api/bot.py b/api/bot.py index 2507977..8814204 100644 --- a/api/bot.py +++ b/api/bot.py @@ -70,6 +70,26 @@ async def send_telegram_alert(markdown_message: str): ) +async def send_telegram_photo(photo, markdown_caption: str): + """Send a photo. `photo` may be raw bytes / a file-like object (uploaded + directly to Telegram) or a public URL string.""" + logger.info("[TELEGRAM] Preparing to send photo...") + kwargs = {} + if TOPIC_ID: + kwargs["message_thread_id"] = int(TOPIC_ID) + await bot.send_photo( + chat_id=str(CHAT_ID), + photo=photo, + caption=markdown_caption, + parse_mode=ParseMode.MARKDOWN_V2, + # Uploading media is slower than a text send; the defaults (5s) time out. + connect_timeout=15, + read_timeout=30, + write_timeout=60, + **kwargs, + ) + + def escape_markdown_v2(text: str) -> str: escape_chars = r"_~`>#+-=|{}.!\\" return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text) diff --git a/api/management/commands/seed.py b/api/management/commands/seed.py index 021e723..829e542 100644 --- a/api/management/commands/seed.py +++ b/api/management/commands/seed.py @@ -8,7 +8,9 @@ class Command(BaseCommand): help = "Seeds baseline reference data (atolls and islands) required for sign up." def handle(self, *args, **options): - atoll, atoll_created = Atoll.objects.get_or_create(name="Faafu") + # Atoll name must match the person-verify API's `atoll_en` (the atoll + # code letter, e.g. "F" for Faafu) or user verification fails. + atoll, atoll_created = Atoll.objects.get_or_create(name="F") self.stdout.write( self.style.SUCCESS(f"Created atoll: {atoll.name}") if atoll_created diff --git a/api/tasks.py b/api/tasks.py index 8ed3004..6b3d8d8 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -9,13 +9,20 @@ from django.utils import timezone # from api.notifications import send_clean_telegram_markdown from api.omada import Omada -from api.bot import send_telegram_alert, telegram_loop, escape_markdown_v2 +from api.bot import ( + send_telegram_alert, + send_telegram_photo, + telegram_loop, + escape_markdown_v2, +) import asyncio from apibase.env import env, BASE_DIR from procrastinate.contrib.django import app from procrastinate import builtin_tasks import time +import io import requests +from PIL import Image logger = logging.getLogger(__name__) @@ -163,10 +170,58 @@ def verify_user_with_person_api_task(user_id: int): response = requests.get(f"{PERSON_VERIFY_BASE_URL}/api/person/{t_user.t_id_card}") - verification_failed_message = f"""*The following user verification failed*:\n\n*ID Card:* {t_user.t_id_card}\n*Name:* {t_user.t_first_name} {t_user.t_last_name}\n*House Name:* {t_user.t_address}\n*Date of Birth:* {t_user.t_dob}\n*Island:* {(t_user.t_atoll.name if t_user.t_atoll else "N/A")} {(t_user.t_island.name if t_user.t_island else "N/A")}\n*Mobile:* {t_user.t_mobile}\nVisit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) to manually verify this user. - """ + user_details = ( + f"*NID:* {t_user.t_id_card}\n" + f"*Name:* {t_user.t_first_name} {t_user.t_last_name}\n" + f"*Phone:* {t_user.t_mobile}\n" + f"*Date of Birth:* {t_user.t_dob}\n" + f"*Address:* {t_user.t_address}\n" + f"*Island:* {(t_user.t_atoll.name if t_user.t_atoll else 'N/A')}. " + f"{(t_user.t_island.name if t_user.t_island else 'N/A')}" + ) - logger.info(verification_failed_message) + verification_failed_message = ( + f"⚠️ *User verification failed*\n\n{user_details}\n" + f"Visit [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details) " + f"to manually verify this user." + ) + + verification_success_message = ( + f"✅ *New user registered and verified*\n\n{user_details}\n" + f"View on [SAR Link Portal](https://portal.sarlink.net/users/{user_id}/details)." + ) + + def _run(coro) -> None: + asyncio.run_coroutine_threadsafe(coro, telegram_loop).result() + + def _fetch_photo(url: str) -> io.BytesIO: + """Download the ID photo and convert it to a Telegram-safe JPEG. + + The image lives on a private-network URL that Telegram's servers can't + reach, so we fetch the bytes here and upload them directly. Conversion + to JPEG avoids Telegram rejecting formats like webp. + """ + resp = requests.get(url, timeout=10) + resp.raise_for_status() + buf = io.BytesIO() + Image.open(io.BytesIO(resp.content)).convert("RGB").save(buf, format="JPEG") + buf.seek(0) + buf.name = "photo.jpg" + return buf + + def send_telegram(message: str, photo_url: str | None = None) -> None: + caption = escape_markdown_v2(message) + if photo_url: + try: + _run(send_telegram_photo(_fetch_photo(photo_url), caption)) + return + except Exception as e: + logger.warning(f"[Registration] TELEGRAM PHOTO ERROR: {e}") + # Fall through to a plain text alert below. + try: + _run(send_telegram_alert(markdown_message=caption)) + except Exception as e: + logger.warning(f"[Registration] TELEGRAM ALERT ERROR: {e}") if response.status_code == 200: @@ -177,6 +232,7 @@ def verify_user_with_person_api_task(user_id: int): api_dob = data.get("dob") api_atoll = data.get("atoll_en") api_island_name = data.get("island_name_en") + api_image_url = data.get("image_url") if not t_user.t_mobile or t_user.t_dob is None: logger.error("User mobile or date of birth is not set.") @@ -230,22 +286,16 @@ def verify_user_with_person_api_task(user_id: int): ): t_user.t_verified = True t_user.save() + + logger.info(verification_success_message) + send_telegram(verification_success_message, photo_url=api_image_url) return True else: t_user.t_verified = False t_user.save() - # send_clean_telegram_markdown(message=verification_failed_message) - - try: - asyncio.run_coroutine_threadsafe( - send_telegram_alert( - markdown_message=escape_markdown_v2(verification_failed_message) - ), - telegram_loop, - ).result() - except Exception as e: - logger.warning("[Registration] TELEGRAM ALERT ERROR", e) + logger.info(verification_failed_message) + send_telegram(verification_failed_message, photo_url=api_image_url) return False else: # Handle the error case