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:
goyban
2026-09-01 21:32:35 +00:00
parent efc2be5f8a
commit 0a8c81db90
7 changed files with 331 additions and 27 deletions
+160 -7
View File
@@ -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 &lt;id&gt; — 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 = {