Initial commit

This commit is contained in:
2026-05-13 23:38:18 +02:00
commit 8b053a7adb
21 changed files with 6642 additions and 0 deletions

240
routers/private.py Normal file
View File

@@ -0,0 +1,240 @@
"""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,
)