Add a vault for saving whole forwarded posts

Completes TODO #5. Forwarding a post now asks whether to save it whole
or extract its media; sending media directly is unchanged.

Built on forwarding rather than copying file_ids. The bot already has
the post in its private chat with the owner, so it stores message ids
and forwards them back. forwardMessages takes a list and keeps an album
intact, which is what made the caption-plus-album case tractable; ids
must be strictly ascending, so they are sorted on the way out.

- Vault rows live in their own table and every query is scoped to an
  owner_id, so a guessed id cannot reach someone else's saved post.
  Nothing in the vault is searchable inline, moderated or federated,
  and the separate table makes that structural rather than a flag.
- Albums arrive as separate messages with no completion signal, so they
  are buffered by media_group_id behind a 1s debounce and prompt once.
- The add path now takes a list of items, so extracting an album stores
  every piece under one set of keywords instead of asking repeatedly.
- /vault searches keywords and the original caption, since the point of
  a vault is finding something you never got round to tagging.
- Retrieval failures are reported: a message id is a pointer into the
  chat, not a self-contained file_id, so clearing history breaks it.

Three near-identical keyword collectors are now one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 22:01:30 +00:00
parent 8f80daaa9e
commit a35abb0541
8 changed files with 650 additions and 52 deletions
+106 -1
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 = 3
SCHEMA_VERSION = 4
# ── schema / migrations ──────────────────────────────────────────────────────
@@ -69,9 +69,28 @@ async def _migrate_to_v3(db) -> None:
await db.execute("CREATE INDEX IF NOT EXISTS idx_status ON media(status)")
async def _migrate_to_v4(db) -> None:
"""The vault: posts kept whole, as pointers to the messages the bot already
holds in its private chat with the owner. Nothing here is ever shared, so it
lives in its own table rather than as a flag on media."""
await db.execute("""
CREATE TABLE IF NOT EXISTS vault_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
chat_id INTEGER NOT NULL,
message_ids TEXT NOT NULL,
caption TEXT NOT NULL DEFAULT '',
keywords TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("CREATE INDEX IF NOT EXISTS idx_vault_owner ON vault_items(owner_id)")
MIGRATIONS = {
2: _migrate_to_v2,
3: _migrate_to_v3,
4: _migrate_to_v4,
}
@@ -246,3 +265,89 @@ async def set_status(media_id: int, status: str, reviewer_id: int) -> bool:
)
await db.commit()
return cur.rowcount > 0
# ── vault ────────────────────────────────────────────────────────────────────
#
# Every query here takes an owner_id and filters on it. A vault item belongs to
# one person and is never searchable, never moderated and never federated; the
# separate table is what makes that structural rather than a flag to remember.
VAULT_COLUMNS = "id, owner_id, chat_id, message_ids, caption, keywords, created_at"
def pack_message_ids(message_ids: list[int]) -> str:
return ",".join(str(m) for m in message_ids)
def unpack_message_ids(packed: str) -> list[int]:
return [int(m) for m in packed.split(",") if m.strip()]
async def add_vault_item(owner_id: int, chat_id: int, message_ids: list[int],
caption: str, keywords: list[str]) -> int:
kw = ",".join(k.strip().lower() for k in keywords if k.strip())
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute(
"""
INSERT INTO vault_items (owner_id, chat_id, message_ids, caption, keywords)
VALUES (?,?,?,?,?)
""",
(owner_id, chat_id, pack_message_ids(message_ids), caption or "", kw),
)
await db.commit()
return cur.lastrowid
async def search_vault(owner_id: int, query: str, limit: int = 20) -> list[dict]:
"""Match keywords by prefix or the original caption anywhere in its text —
the point of a vault is finding a post you never got round to tagging."""
query = query.strip().lower()
params: list = [owner_id]
clause = ""
if query:
clause = "AND ((',' || keywords || ',') LIKE ? OR LOWER(caption) LIKE ?)"
params += [f"%,{query}%", f"%{query}%"]
params.append(limit)
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
f"""
SELECT {VAULT_COLUMNS} FROM vault_items
WHERE owner_id = ? {clause}
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
params,
)
return [dict(r) for r in await cursor.fetchall()]
async def get_vault_item(owner_id: int, item_id: int) -> dict | None:
"""Scoped by owner so one user cannot fetch another user's saved post."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
f"SELECT {VAULT_COLUMNS} FROM vault_items WHERE id = ? AND owner_id = ?",
(item_id, owner_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def delete_vault_item(owner_id: int, item_id: int) -> bool:
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute(
"DELETE FROM vault_items WHERE id = ? AND owner_id = ?", (item_id, owner_id)
)
await db.commit()
return cur.rowcount > 0
async def count_vault(owner_id: int) -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"SELECT COUNT(*) FROM vault_items WHERE owner_id = ?", (owner_id,)
)
return (await cursor.fetchone())[0]