"""Handlers for private (DM) interactions: TOS, menu, upload FSM, chat.""" import logging import time from aiogram import F, Router, types from aiogram.filters import Command, CommandStart from aiogram.fsm.context import FSMContext import bot_state as state_store from config import ( CHAT_CONTACT_ADMIN_GROUP_ID, DAILY_SUBMISSION_LIMIT, REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID, INVITELINK_ARCH, INVITELINK_CHAT, BOT_USERNAME, ) from keyboards import admin_kb, anonymous_choice_kb, confirm_kb, menu_kb from persistence import save_confirmed_users from states import ChatSetup, Upload from utils import ( build_message_preview, build_quote, cancel_upload_prompt, get_admin_display_name, schedule_upload_prompt, send_media_items, start_chat_session, ) logger = logging.getLogger(__name__) router = Router(name="private") # ── TOS confirm ─────────────────────────────────────────────────────────────── @router.callback_query(F.data == "tos_confirm") async def tos_confirm_cb(cb: types.CallbackQuery): state_store.confirmed_users.add(cb.from_user.id) save_confirmed_users() try: await cb.message.edit_reply_markup(reply_markup=None) except Exception: pass await cb.message.answer("Choose:", reply_markup=menu_kb()) await cb.answer() # ── /start ──────────────────────────────────────────────────────────────────── @router.message(CommandStart()) async def cmd_start(message: types.Message, state: FSMContext): await message.answer( "The other videos/images can be found in the channel.\n" "Channel invite links:\n" f"[Archive]({INVITELINK_ARCH}) | [Chat]({INVITELINK_CHAT})", parse_mode="Markdown", ) await message.answer("Choose:", reply_markup=menu_kb()) # ── Menu buttons ────────────────────────────────────────────────────────────── @router.callback_query(F.data == "menu_upload") async def menu_upload_cb(cb: types.CallbackQuery, state: FSMContext): if cb.from_user.id in state_store.chat_sessions: await cb.answer( "Your chat is active. Send /stop to return to the submission menu.", show_alert=True, ) return cancel_upload_prompt(cb.from_user.id) await state.set_state(Upload.waiting_media) await state.update_data(media=[]) await cb.message.answer("Send photos or videos. You can send multiple, then press Submit.") await cb.answer() @router.callback_query(F.data == "menu_chat") async def menu_chat_cb(cb: types.CallbackQuery, state: FSMContext): if cb.from_user.id in state_store.banned_chat_users: await cb.answer("You are not allowed to contact the administrators.", show_alert=True) return if cb.from_user.id in state_store.chat_sessions: await cb.answer( "Your chat is already active. Send /stop to close it.", show_alert=True ) return cancel_upload_prompt(cb.from_user.id) await state.clear() await state.set_state(ChatSetup.waiting_anonymous_choice) await cb.message.answer("Do you want to remain anonymous?", reply_markup=anonymous_choice_kb) await cb.answer() # ── Anonymous choice ────────────────────────────────────────────────────────── @router.callback_query(F.data == "menu_anon_yes") async def menu_anon_yes_cb(cb: types.CallbackQuery, state: FSMContext): await state.set_state(ChatSetup.waiting_anonymous_name) await cb.message.answer("What name do you want to use?") await cb.answer() @router.callback_query(F.data == "menu_anon_no") async def menu_anon_no_cb(cb: types.CallbackQuery, state: FSMContext): await start_chat_session(cb.bot, cb.from_user, cb.message.answer, state) await cb.answer() @router.message(ChatSetup.waiting_anonymous_name, F.text) async def anonymous_name(message: types.Message, state: FSMContext): name = message.text.strip() if not name: await message.answer("Please send a valid name.") return await start_chat_session(message.bot, message.from_user, message.answer, state, anonymous_name=name) # ── /stop ───────────────────────────────────────────────────────────────────── @router.message(Command("stop"), F.chat.type == "private") async def cmd_stop(message: types.Message, state: FSMContext): cancel_upload_prompt(message.from_user.id) state_store.upload_prompt_msg_ids.pop(message.from_user.id, None) await state.clear() if state_store.chat_sessions.pop(message.from_user.id, None): await message.answer("Chat stopped.", reply_markup=menu_kb()) else: await message.answer("Choose:", reply_markup=menu_kb()) # ── Media upload FSM ────────────────────────────────────────────────────────── @router.message(Upload.waiting_media, F.photo | F.video) async def handle_media(message: types.Message, state: FSMContext): if message.photo: file_id, file_unique_id, file_type = ( message.photo[-1].file_id, message.photo[-1].file_unique_id, "photo", ) else: file_id, file_unique_id, file_type = ( message.video.file_id, message.video.file_unique_id, "video", ) data = await state.get_data() media = data.get("media", []) media.append({"file_id": file_id, "file_unique_id": file_unique_id, "type": file_type}) await state.update_data(media=media) await schedule_upload_prompt(message.bot, message.chat.id, message.from_user.id) @router.callback_query(F.data == "submit") async def cb_submit(cb: types.CallbackQuery, state: FSMContext): if cb.from_user.id in state_store.submitting_users: await cb.answer() return state_store.submitting_users.add(cb.from_user.id) try: cancel_upload_prompt(cb.from_user.id) state_store.upload_prompt_msg_ids.pop(cb.from_user.id, None) data = await state.get_data() media = list(data.get("media", [])) if not media: await cb.answer("Send at least one photo or video first.", show_alert=True) return user = cb.from_user current_day = int(time.time() // 86400) user_limit = state_store.daily_submissions.get(user.id) if not user_limit or user_limit["day"] != current_day: user_limit = {"day": current_day, "count": 0} state_store.daily_submissions[user.id] = user_limit if user_limit["count"] >= DAILY_SUBMISSION_LIMIT: await cb.message.answer( f"You reached your daily limit of {DAILY_SUBMISSION_LIMIT} submissions. " "Please try again tomorrow." ) await cb.answer() return state_store.counter += 1 sub_id = state_store.counter state_store.submissions[sub_id] = {"user_id": user.id, "media": media} caption = "New submission from @" + (user.username or user.full_name) try: await send_media_items(cb.bot, REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID, media) await cb.bot.send_message( REVIEW_ADMIN_CHATROOM_SEMIPUBLIC_GROUP_ID, caption, reply_markup=admin_kb(sub_id), ) except Exception: logger.exception("Failed to forward submission %s to review group", sub_id) user_limit["count"] += 1 await state.clear() await cb.message.answer("Submitted ✅", reply_markup=menu_kb()) await cb.answer() finally: state_store.submitting_users.discard(cb.from_user.id) @router.callback_query(F.data == "cancel") async def cb_cancel(cb: types.CallbackQuery, state: FSMContext): cancel_upload_prompt(cb.from_user.id) state_store.upload_prompt_msg_ids.pop(cb.from_user.id, None) await state.clear() await cb.message.answer("Cancelled.", reply_markup=menu_kb()) await cb.answer() # ── Active chat (DM → admin group) ─────────────────────────────────────────── @router.message(F.chat.type == "private") async def active_chat_router(message: types.Message, state: FSMContext): session = state_store.chat_sessions.get(message.from_user.id) if not session: return preview = build_message_preview(message) header = f"From: {session['display_name']}\n\n{preview}" try: if message.text: sent = await message.bot.send_message(CHAT_CONTACT_ADMIN_GROUP_ID, header) state_store.chat_message_map[sent.message_id] = { "user_id": message.from_user.id, "quote": preview, } else: info_msg = await message.bot.send_message(CHAT_CONTACT_ADMIN_GROUP_ID, header) copied_msg = await message.bot.copy_message( CHAT_CONTACT_ADMIN_GROUP_ID, message.chat.id, message.message_id ) for mid in (info_msg.message_id, copied_msg.message_id): state_store.chat_message_map[mid] = { "user_id": message.from_user.id, "quote": preview, } except Exception: logger.exception( "Failed to forward chat message from user %s to admin group", message.from_user.id, )