"""Pinned duty message in groups: handle bot add/remove, schedule updates at shift end.""" import asyncio import logging from datetime import datetime, timezone from zoneinfo import ZoneInfo import config from telegram import Update from telegram.constants import ChatMemberStatus from telegram.error import BadRequest, Forbidden from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes from db.session import get_session from db.repository import ( get_current_duty, get_next_shift_end, get_group_duty_pin, save_group_duty_pin, delete_group_duty_pin, get_all_group_duty_pin_chat_ids, ) logger = logging.getLogger(__name__) JOB_NAME_PREFIX = "duty_pin_" RETRY_WHEN_NO_DUTY_MINUTES = 15 def _format_duty_message(duty, user, tz_name: str) -> str: """Build the text for the pinned message. duty, user may be None.""" if duty is None or user is None: return "Сейчас дежурства нет." try: tz = ZoneInfo(tz_name) except Exception: tz = ZoneInfo("Europe/Moscow") tz_name = "Europe/Moscow" start_dt = datetime.fromisoformat(duty.start_at.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(duty.end_at.replace("Z", "+00:00")) start_local = start_dt.astimezone(tz) end_local = end_dt.astimezone(tz) # Показать смещение (UTC+3) чтобы было понятно, в каком поясе время offset_sec = start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0 sign = "+" if offset_sec >= 0 else "-" h, r = divmod(abs(int(offset_sec)), 3600) m = r // 60 tz_hint = f"UTC{sign}{h:d}:{m:02d}, {tz_name}" time_range = f"{start_local.strftime('%d.%m.%Y %H:%M')} — {end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})" lines = [ f"🕐 Дежурство: {time_range}", f"👤 {user.full_name}", ] if user.phone: lines.append(f"📞 {user.phone}") if user.username: lines.append(f"@{user.username}") return "\n".join(lines) def _get_duty_message_text() -> str: """Get current duty from DB and return formatted message (sync, for run_in_executor).""" session = get_session(config.DATABASE_URL) try: now = datetime.now(timezone.utc) result = get_current_duty(session, now) if result is None: return "Сейчас дежурства нет." duty, user = result return _format_duty_message(duty, user, config.DUTY_DISPLAY_TZ) finally: session.close() def _get_next_shift_end_utc(): """Return next shift end as naive UTC datetime for job scheduling (sync).""" session = get_session(config.DATABASE_URL) try: return get_next_shift_end(session, datetime.now(timezone.utc)) finally: session.close() def _sync_save_pin(chat_id: int, message_id: int) -> None: session = get_session(config.DATABASE_URL) try: save_group_duty_pin(session, chat_id, message_id) finally: session.close() def _sync_delete_pin(chat_id: int) -> None: session = get_session(config.DATABASE_URL) try: delete_group_duty_pin(session, chat_id) finally: session.close() def _sync_get_message_id(chat_id: int) -> int | None: session = get_session(config.DATABASE_URL) try: pin = get_group_duty_pin(session, chat_id) return pin.message_id if pin else None finally: session.close() async def _schedule_next_update( application, chat_id: int, when_utc: datetime | None ) -> None: """Schedule run_once for update_group_pin. Remove existing job with same name first.""" job_queue = application.job_queue if job_queue is None: logger.warning("Job queue not available, cannot schedule pin update") return name = f"{JOB_NAME_PREFIX}{chat_id}" for job in job_queue.get_jobs_by_name(name): job.schedule_removal() if when_utc is not None: delay = when_utc - datetime.utcnow() if delay.total_seconds() < 1: delay = 1 job_queue.run_once( update_group_pin, when=delay, data={"chat_id": chat_id}, name=name, ) logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc) else: from datetime import timedelta job_queue.run_once( update_group_pin, when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES), data={"chat_id": chat_id}, name=name, ) logger.info( "No next shift for chat_id=%s; scheduled retry in %s min", chat_id, RETRY_WHEN_NO_DUTY_MINUTES, ) async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: """Job callback: edit pinned message with current duty and schedule next update.""" chat_id = context.job.data.get("chat_id") if chat_id is None: return loop = asyncio.get_running_loop() message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) if message_id is None: logger.info("No pin record for chat_id=%s, skipping update", chat_id) return text = await loop.run_in_executor(None, _get_duty_message_text) try: await context.bot.edit_message_text( chat_id=chat_id, message_id=message_id, text=text, ) except (BadRequest, Forbidden) as e: logger.warning("Failed to edit pinned message chat_id=%s: %s", chat_id, e) next_end = await loop.run_in_executor(None, _get_next_shift_end_utc) await _schedule_next_update(context.application, chat_id, next_end) async def my_chat_member_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """On bot added to group: send, pin, save, schedule. On removed: delete pin, cancel job.""" if not update.my_chat_member or not update.effective_user: return old = update.my_chat_member.old_chat_member new = update.my_chat_member.new_chat_member chat = update.effective_chat if not chat or chat.type not in ("group", "supergroup"): return if new.user.id != context.bot.id: return chat_id = chat.id # Bot added to group if new.status in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR) and old.status in ( ChatMemberStatus.LEFT, ChatMemberStatus.BANNED, ): loop = asyncio.get_running_loop() text = await loop.run_in_executor(None, _get_duty_message_text) try: msg = await context.bot.send_message(chat_id=chat_id, text=text) except (BadRequest, Forbidden) as e: logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e) return # Make it a pinned post (bot must be admin with "Pin messages" right) pinned = False try: await context.bot.pin_chat_message( chat_id=chat_id, message_id=msg.message_id, disable_notification=True, ) pinned = True except (BadRequest, Forbidden) as e: logger.warning("Failed to pin message in chat_id=%s: %s", chat_id, e) await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id) if not pinned: try: await context.bot.send_message( chat_id=chat_id, text="Сообщение о дежурстве отправлено, но закрепить его не удалось. " "Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), " "затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.", ) except (BadRequest, Forbidden): pass next_end = await loop.run_in_executor(None, _get_next_shift_end_utc) await _schedule_next_update(context.application, chat_id, next_end) return # Bot removed from group if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED): await asyncio.get_running_loop().run_in_executor(None, _sync_delete_pin, chat_id) name = f"{JOB_NAME_PREFIX}{chat_id}" if context.application.job_queue: for job in context.application.job_queue.get_jobs_by_name(name): job.schedule_removal() logger.info("Bot left chat_id=%s, removed pin record and jobs", chat_id) def _get_all_pin_chat_ids_sync() -> list[int]: session = get_session(config.DATABASE_URL) try: return get_all_group_duty_pin_chat_ids(session) finally: session.close() async def restore_group_pin_jobs(application) -> None: """On startup: for each chat_id in group_duty_pins, schedule next update at shift end.""" loop = asyncio.get_running_loop() chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync) for chat_id in chat_ids: next_end = await loop.run_in_executor(None, _get_next_shift_end_utc) await _schedule_next_update(application, chat_id, next_end) logger.info("Restored %s group pin jobs", len(chat_ids)) async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Pin the current duty message (use after granting the bot 'Pin messages' right).""" if not update.message or not update.effective_chat: return chat = update.effective_chat if chat.type not in ("group", "supergroup"): await update.message.reply_text("Команда /pin_duty работает только в группах.") return chat_id = chat.id loop = asyncio.get_running_loop() message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) if message_id is None: await update.message.reply_text("В этом чате ещё нет сообщения о дежурстве. Добавьте бота в группу — оно создастся автоматически.") return try: await context.bot.pin_chat_message( chat_id=chat_id, message_id=message_id, disable_notification=True, ) await update.message.reply_text("Сообщение о дежурстве закреплено.") except (BadRequest, Forbidden) as e: logger.warning("pin_duty failed chat_id=%s: %s", chat_id, e) await update.message.reply_text( "Не удалось закрепить. Убедитесь, что бот — администратор с правом «Закреплять сообщения»." ) group_duty_pin_handler = ChatMemberHandler( my_chat_member_handler, ChatMemberHandler.MY_CHAT_MEMBER, ) pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)