Add a moderation queue for user submissions
Completes TODO #3. Submissions are gated behind ACCEPT_SUBMISSIONS, which defaults to off: is_allowed() treats everyone as allowed when ALLOWED_USERS is empty, so opening submissions implicitly would have accepted media from anyone who found the bot. - Schema v3 adds status, reviewed_by and reviewed_at. Existing rows default to approved, so upgrading changes nothing for a running bot. - Search, /list and counts only return approved items; callers that need everything pass status=None. - /review shows the oldest pending item with approve, reject, fix keywords and stop. No cursor is stored anywhere: each action just asks for the next pending item, which is what lets a moderator stop and resume later, and lets several moderators share one queue. - set_status only updates rows still pending, so if two moderators open the same item the first decision wins and the second is told so. - A failed send no longer wedges the queue; the item's details are shown anyway so it can still be rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,10 @@ ADMIN_USERS: set[int] = {
|
||||
int(uid.strip()) for uid in os.environ.get("ADMIN_USERS", "").split(",") if uid.strip()
|
||||
}
|
||||
|
||||
# Off by default: with ALLOWED_USERS unset, is_allowed() lets everyone in, so
|
||||
# opening submissions implicitly would accept media from the whole world.
|
||||
ACCEPT_SUBMISSIONS = os.environ.get("ACCEPT_SUBMISSIONS", "").strip().lower() in {"1", "true", "yes"}
|
||||
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
|
||||
@@ -52,6 +56,7 @@ USER_COMMANDS = [
|
||||
]
|
||||
ADMIN_COMMANDS = [
|
||||
*USER_COMMANDS,
|
||||
BotCommand(command="review", description="Review pending submissions"),
|
||||
BotCommand(command="edit", description="Edit meme keywords"),
|
||||
BotCommand(command="delete", description="Remove a meme by ID"),
|
||||
]
|
||||
@@ -98,10 +103,17 @@ def menu_text(user_id: int) -> str:
|
||||
"Send me any <b>photo, GIF, video, voice, music, sticker, or file</b>.\n"
|
||||
"I'll ask you for keywords next.\n\n"
|
||||
"/list — show all saved memes\n"
|
||||
"/review — review pending submissions\n"
|
||||
"/edit [search] — edit keywords of a meme\n"
|
||||
"/delete <id> — remove a meme by ID\n"
|
||||
"/cancel — cancel current operation"
|
||||
)
|
||||
if ACCEPT_SUBMISSIONS:
|
||||
return (
|
||||
"Use me inline: type <code>@botname keyword</code> in any chat.\n\n"
|
||||
"Send me media to submit it — a moderator will review it before it goes live.\n\n"
|
||||
"/list — browse all saved memes"
|
||||
)
|
||||
return (
|
||||
"Use me inline: type <code>@botname keyword</code> in any chat.\n\n"
|
||||
"/list — browse all saved memes"
|
||||
@@ -141,13 +153,15 @@ async def cmd_cancel(msg: Message, state: FSMContext):
|
||||
|
||||
# ── step 1: receive media (admin only) ───────────────────────────────────────
|
||||
|
||||
async def _receive_media(msg: Message, state: FSMContext, file_id: str, file_unique_id: str, media_type: str):
|
||||
async def _receive_media(msg: Message, state: FSMContext, file_id: str, file_unique_id: str,
|
||||
media_type: str, status: str = "approved"):
|
||||
await state.set_state(AddMedia.waiting_for_keywords)
|
||||
await state.update_data(
|
||||
file_id=file_id,
|
||||
file_unique_id=file_unique_id,
|
||||
media_type=media_type,
|
||||
keywords=[],
|
||||
status=status,
|
||||
)
|
||||
await msg.reply(
|
||||
"Got it! Now send keywords — one per message or comma-separated.\n"
|
||||
@@ -188,13 +202,18 @@ def extract_media(msg: Message) -> tuple[str, str, str] | None:
|
||||
|
||||
@dp.message(MEDIA_FILTER, StateFilter(None))
|
||||
async def on_media(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
user_id = msg.from_user.id
|
||||
if is_admin(user_id):
|
||||
status = "approved"
|
||||
elif ACCEPT_SUBMISSIONS and is_allowed(user_id):
|
||||
status = "pending"
|
||||
else:
|
||||
return
|
||||
found = extract_media(msg)
|
||||
if found is None:
|
||||
return
|
||||
media_type, file_id, file_unique_id = found
|
||||
await _receive_media(msg, state, file_id, file_unique_id, media_type)
|
||||
await _receive_media(msg, state, file_id, file_unique_id, media_type, status)
|
||||
|
||||
|
||||
# ── step 2: collect keywords ─────────────────────────────────────────────────
|
||||
@@ -206,15 +225,24 @@ async def cmd_done(msg: Message, state: FSMContext):
|
||||
if not keywords:
|
||||
await msg.answer("You haven't added any keywords yet. Send some, then /done.")
|
||||
return
|
||||
status = data.get("status", "approved")
|
||||
await db.add_media(
|
||||
msg.from_user.id,
|
||||
data["file_id"],
|
||||
data["file_unique_id"],
|
||||
data["media_type"],
|
||||
keywords,
|
||||
status=status,
|
||||
)
|
||||
await state.clear()
|
||||
await msg.answer(f"Saved with keywords: <b>{', '.join(keywords)}</b>", parse_mode="HTML")
|
||||
if status == "pending":
|
||||
await msg.answer(
|
||||
f"Submitted with keywords: <b>{', '.join(keywords)}</b>\n"
|
||||
"A moderator will review it before it becomes searchable.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await msg.answer(f"Saved with keywords: <b>{', '.join(keywords)}</b>", parse_mode="HTML")
|
||||
|
||||
|
||||
@dp.message(AddMedia.waiting_for_keywords, F.text)
|
||||
@@ -357,11 +385,20 @@ async def cmd_edit_done(msg: Message, state: FSMContext):
|
||||
await msg.answer("You haven't entered any keywords yet. Send some, then /done.")
|
||||
return
|
||||
updated = await db.update_media_keywords(data["media_id"], keywords)
|
||||
from_review = data.get("from_review", False)
|
||||
await state.clear()
|
||||
if updated:
|
||||
await msg.answer(f"Updated keywords: <b>{', '.join(keywords)}</b>", parse_mode="HTML")
|
||||
else:
|
||||
|
||||
if not updated:
|
||||
await msg.answer("Failed to update (meme may have been deleted).")
|
||||
return
|
||||
|
||||
if from_review:
|
||||
# The item is still pending, so the queue hands the same one back with
|
||||
# its new keywords — ready to approve.
|
||||
await send_next_pending(msg.chat.id, "Keywords updated.")
|
||||
return
|
||||
|
||||
await msg.answer(f"Updated keywords: <b>{', '.join(keywords)}</b>", parse_mode="HTML")
|
||||
|
||||
|
||||
@dp.message(EditMedia.waiting_for_new_keywords, Command("cancel"))
|
||||
@@ -382,6 +419,122 @@ async def on_edit_keyword(msg: Message, state: FSMContext):
|
||||
await msg.reply(f"Keywords so far: <b>{', '.join(merged)}</b>\nSend more or /done.", parse_mode="HTML")
|
||||
|
||||
|
||||
# ── /review ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Sending a stored item back needs one call per media type. Stickers cannot
|
||||
# carry a caption, so the item and its review buttons are sent as two messages
|
||||
# rather than special-casing captions per type.
|
||||
MEDIA_SENDERS = {
|
||||
"photo": lambda chat_id, fid: bot.send_photo(chat_id, fid),
|
||||
"gif": lambda chat_id, fid: bot.send_animation(chat_id, fid),
|
||||
"video": lambda chat_id, fid: bot.send_video(chat_id, fid),
|
||||
"voice": lambda chat_id, fid: bot.send_voice(chat_id, fid),
|
||||
"audio": lambda chat_id, fid: bot.send_audio(chat_id, fid),
|
||||
"sticker": lambda chat_id, fid: bot.send_sticker(chat_id, fid),
|
||||
"document": lambda chat_id, fid: bot.send_document(chat_id, fid),
|
||||
}
|
||||
|
||||
|
||||
def review_keyboard(media_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Approve", callback_data=f"review:approve:{media_id}"),
|
||||
InlineKeyboardButton(text="❌ Reject", callback_data=f"review:reject:{media_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Fix keywords", callback_data=f"review:edit:{media_id}"),
|
||||
InlineKeyboardButton(text="⏸ Stop", callback_data="review:stop:0"),
|
||||
],
|
||||
])
|
||||
|
||||
|
||||
async def send_next_pending(chat_id: int, notice: str = "") -> None:
|
||||
"""Show the next item awaiting review, or report the queue is empty.
|
||||
|
||||
Nothing tracks where a moderator got to — each call asks the database for
|
||||
the oldest pending item. Stopping and resuming later needs no state, and
|
||||
two moderators working at once simply take items off the same queue.
|
||||
"""
|
||||
item = await db.next_pending()
|
||||
prefix = f"{notice}\n\n" if notice else ""
|
||||
|
||||
if item is None:
|
||||
await bot.send_message(chat_id, f"{prefix}Nothing left to review.")
|
||||
return
|
||||
|
||||
remaining = await db.count_pending()
|
||||
send = MEDIA_SENDERS.get(item["media_type"])
|
||||
if send is not None:
|
||||
try:
|
||||
await send(chat_id, item["file_id"])
|
||||
except Exception:
|
||||
# A stale or unusable file_id must not wedge the queue — show the
|
||||
# details anyway so the moderator can still reject it.
|
||||
log.exception("could not send pending item %s for review", item["id"])
|
||||
await bot.send_message(chat_id, "⚠️ Could not load this media.")
|
||||
else:
|
||||
log.warning("no sender for media_type %r (item %s)", item["media_type"], item["id"])
|
||||
await bot.send_message(chat_id, f"⚠️ Unsupported media type: {item['media_type']}")
|
||||
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
f"{prefix}Pending <b>#{item['id']}</b> [{item['media_type']}]\n"
|
||||
f"From user <code>{item['owner_id']}</code>\n"
|
||||
f"Keywords: <b>{item['keywords'].replace(',', ', ')}</b>\n\n"
|
||||
f"{remaining} awaiting review.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=review_keyboard(item["id"]),
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("review"))
|
||||
async def cmd_review(msg: Message):
|
||||
if not is_admin(msg.from_user.id):
|
||||
await msg.answer("Only moderators can review submissions.")
|
||||
return
|
||||
await send_next_pending(msg.chat.id)
|
||||
|
||||
|
||||
@dp.callback_query(F.data.startswith("review:"))
|
||||
async def on_review_action(callback: CallbackQuery, state: FSMContext):
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("Not allowed.", show_alert=True)
|
||||
return
|
||||
|
||||
_, action, raw_id = callback.data.split(":", 2)
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
|
||||
if action == "stop":
|
||||
await callback.message.answer("Review paused. Send /review to pick up where you left off.")
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
media_id = int(raw_id)
|
||||
|
||||
if action == "edit":
|
||||
item = await db.get_media_by_id(media_id)
|
||||
if not item:
|
||||
await send_next_pending(callback.message.chat.id, "That item is gone.")
|
||||
await callback.answer()
|
||||
return
|
||||
await state.set_state(EditMedia.waiting_for_new_keywords)
|
||||
await state.update_data(media_id=media_id, keywords=[], from_review=True)
|
||||
await callback.message.answer(
|
||||
f"Editing <b>#{item['id']}</b>\n"
|
||||
f"Current keywords: <b>{item['keywords']}</b>\n\n"
|
||||
"Send the new keywords, then /done. /cancel stops reviewing.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
status = "approved" if action == "approve" else "rejected"
|
||||
changed = await db.set_status(media_id, status, callback.from_user.id)
|
||||
notice = f"#{media_id} {status}." if changed else f"#{media_id} was already handled."
|
||||
await send_next_pending(callback.message.chat.id, notice)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Cached inline results, keyed by the media_type stored in the database.
|
||||
# Audio carries its own title metadata; video and document require one.
|
||||
INLINE_BUILDERS = {
|
||||
|
||||
Reference in New Issue
Block a user