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
+69 -13
View File
@@ -4,7 +4,7 @@ import os
DB_PATH = os.getenv("DB_PATH", "/data/media.db")
# Bump this and add a matching _migrate_to_vN() when the schema changes.
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
# ── schema / migrations ──────────────────────────────────────────────────────
@@ -60,8 +60,18 @@ async def _migrate_to_v2(db) -> None:
await db.execute("CREATE INDEX IF NOT EXISTS idx_owner ON media(owner_id)")
async def _migrate_to_v3(db) -> None:
"""Add the moderation queue. Existing rows default to approved, so an
upgraded deployment behaves exactly as it did before."""
await db.execute("ALTER TABLE media ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'")
await db.execute("ALTER TABLE media ADD COLUMN reviewed_by INTEGER")
await db.execute("ALTER TABLE media ADD COLUMN reviewed_at TIMESTAMP")
await db.execute("CREATE INDEX IF NOT EXISTS idx_status ON media(status)")
MIGRATIONS = {
2: _migrate_to_v2,
3: _migrate_to_v3,
}
@@ -90,21 +100,23 @@ async def init_db():
# ── writes ───────────────────────────────────────────────────────────────────
async def add_media(owner_id: int, file_id: str, file_unique_id: str, media_type: str, keywords: list[str]):
async def add_media(owner_id: int, file_id: str, file_unique_id: str, media_type: str,
keywords: list[str], status: str = "approved"):
kw = ",".join(k.strip().lower() for k in keywords if k.strip())
async with aiosqlite.connect(DB_PATH) as db:
# Upsert rather than INSERT OR REPLACE: replacing would delete and
# reinsert the row, handing it a new id and breaking /delete <id>.
await db.execute(
"""
INSERT INTO media (owner_id, file_id, file_unique_id, media_type, keywords)
VALUES (?,?,?,?,?)
INSERT INTO media (owner_id, file_id, file_unique_id, media_type, keywords, status)
VALUES (?,?,?,?,?,?)
ON CONFLICT(owner_id, file_unique_id) DO UPDATE SET
file_id = excluded.file_id,
media_type = excluded.media_type,
keywords = excluded.keywords
keywords = excluded.keywords,
status = excluded.status
""",
(owner_id, file_id, file_unique_id, media_type, kw),
(owner_id, file_id, file_unique_id, media_type, kw, status),
)
await db.commit()
@@ -129,15 +141,20 @@ async def delete_media(media_id: int) -> bool:
# ── reads ────────────────────────────────────────────────────────────────────
COLUMNS = "id, file_id, file_unique_id, media_type, keywords"
COLUMNS = "id, owner_id, file_id, file_unique_id, media_type, keywords, status"
async def search_media(query: str, owner_id: int | None = None) -> list[dict]:
async def search_media(query: str, owner_id: int | None = None,
status: str | None = "approved") -> list[dict]:
"""Search by keyword prefix. owner_id=None searches every owner, which is
the current shared-library behaviour; public mode will pass a scope."""
the current shared-library behaviour; public mode will pass a scope.
status=None includes items still awaiting moderation."""
query = query.strip().lower()
where = []
params: list = []
if status is not None:
where.append("status = ?")
params.append(status)
if query:
where.append("(',' || keywords || ',') LIKE ?")
params.append(f"%,{query}%")
@@ -156,27 +173,32 @@ async def search_media(query: str, owner_id: int | None = None) -> list[dict]:
return [dict(r) for r in rows]
async def list_media(page: int, page_size: int) -> list[dict]:
async def list_media(page: int, page_size: int, status: str | None = "approved") -> list[dict]:
"""Return one page of saved media, newest first."""
offset = page * page_size
clause = "WHERE status = ?" if status is not None else ""
params = ([status] if status is not None else []) + [page_size, offset]
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
f"""
SELECT {COLUMNS}
FROM media
{clause}
ORDER BY added_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(page_size, offset),
params,
)
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def count_media() -> int:
async def count_media(status: str | None = "approved") -> int:
clause = "WHERE status = ?" if status is not None else ""
params = [status] if status is not None else []
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT COUNT(*) FROM media")
cursor = await db.execute(f"SELECT COUNT(*) FROM media {clause}", params)
row = await cursor.fetchone()
return row[0]
@@ -190,3 +212,37 @@ async def get_media_by_id(media_id: int) -> dict | None:
)
row = await cursor.fetchone()
return dict(row) if row else None
# ── moderation queue ─────────────────────────────────────────────────────────
async def next_pending() -> dict | None:
"""Oldest item awaiting review. The queue keeps no cursor: each call just
asks for the next one, so a moderator can stop and resume at any time and
several moderators can work at once."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
f"SELECT {COLUMNS} FROM media WHERE status = 'pending' ORDER BY added_at ASC, id ASC LIMIT 1"
)
row = await cursor.fetchone()
return dict(row) if row else None
async def count_pending() -> int:
return await count_media(status="pending")
async def set_status(media_id: int, status: str, reviewer_id: int) -> bool:
"""Approve or reject a pending item. Returns False if it was not pending
any more, which is how two moderators reviewing at once are kept apart."""
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute(
"""
UPDATE media SET status = ?, reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'pending'
""",
(status, reviewer_id, media_id),
)
await db.commit()
return cur.rowcount > 0