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
+33 -17
View File
@@ -7,10 +7,10 @@ Progress on branch `develop` (not pushed):
- [x] **#6** full media coverage — `efc2be5`
- [x] **#3** moderation queue — `0a8c81d`
- [x] **#2** public mode + membership gate — `82b5da4`
- [ ] **#5** save vs. extract (vault)
- [x] **#5** save vs. extract (vault) — forward-based
- [ ] **#4** federation
Test suites: `test_db.py`, `test_bot.py`, `test_access.py` — 20 cases, all passing.
Test suites: `test_db.py`, `test_bot.py`, `test_access.py`, `test_vault.py` — 29 cases, all passing.
---
@@ -98,29 +98,45 @@ With `PUBLIC_MODE=true`, non-admin submissions land as `status='pending'` from #
---
## #5 — Save vs. extract on forward
## #5 — Save vs. extract on forward — **implemented**
On a forwarded message, ask with inline buttons: **[📥 Save whole post]** / **[✂️ Extract media]**. Extract = today's behavior. Save = the private vault.
Built on forwarding rather than the file_id copy originally planned here, which
removed most of the work.
**Albums are the hard part.** A post with caption + multiple media arrives as *several separate messages* sharing a `media_group_id`, with no "album complete" signal. Standard approach: buffer by `media_group_id` and debounce ~1s after the last message before prompting. Ask once per album, not once per file.
**The bot already holds the post.** When you forward something, Telegram puts a
copy in your private chat with the bot, so the bot can forward *that* back later
by `message_id`. It never needs to reach the original channel, store media, or
re-upload anything.
**Schema (migration v4)** — a separate table, not a `media` column, since one vault item holds N files:
**`forwardMessages` (plural) preserves albums.** It takes up to 100 message ids
and returns them as one intact album, so the caption + N media case that made
this item large is handled by Telegram itself. Ids must be strictly ascending.
**One table, not two:**
```sql
vault_items(id, owner_id, caption TEXT, keywords TEXT, source_chat TEXT, created_at)
vault_files(item_id, file_id, file_unique_id, media_type, position)
vault_items(id, owner_id, chat_id, message_ids TEXT, caption, keywords, created_at)
```
Vault rows are **always private** and never touched by the federation sync in #4 — enforce that structurally by keeping them in a different table, so no `visibility` flag can ever be mis-set.
Every vault query takes an `owner_id` and filters on it, so a guessed id cannot
reach another user's post. Vault rows live in their own table and are never
searched by inline mode, never moderated, and never federated.
**Search should cover the caption, not just keywords** — the point of a vault is finding a post you saved without having tagged it. This is where the current `LIKE '%,kw%'` approach (`db.py:43`) stops being adequate; use an FTS5 virtual table over `caption + keywords`.
**Flow.** A forwarded message prompts *Save whole post* / *Extract media*
(extract only appears when there is media). Save collects keywords and stores
the pointer; extract runs the existing add path, now generalised to a list so a
whole album shares one set of keywords. Albums are buffered by `media_group_id`
with a 1s debounce, since Telegram gives no "album complete" signal.
**Open question — recall UX.** Telegram **inline mode cannot send an album**; one inline result sends exactly one message. So a multi-file vault item can't come back through `@bot query` intact. Two options:
**Recall is inside the bot only**, as decided: `/vault [search]` lists matching
posts with a retrieve button and a delete button. Matching covers keywords and
the original caption. The save confirmation tells the user plainly that vault
posts do not appear in inline search.
1. Inline returns the item's *first* media with the caption attached — works everywhere, but loses the other files.
2. A `/vault <query>` command in the bot's private chat that replies with the full album, which you then forward manually — preserves everything, costs one extra step.
These aren't exclusive; doing both (1 for speed, 2 for fidelity) is probably right, but it's your call. Single-file vault items are unaffected either way.
**The tradeoff accepted:** a `message_id` is a pointer into the bot's chat with
the owner, not self-contained like a `file_id`. If that history is cleared, the
pointer breaks; retrieval catches the failure and tells the user to forward the
post again.
---
@@ -164,14 +180,14 @@ The hub is a separate deployable (small FastAPI + SQLite would do). Do you want
| 6 | Full media coverage | 0 (only for the refactor) | S |
| 3 | Moderation queue | 0 | M |
| 2 | Public mode + membership gate | 0, 3 | M |
| 5 | Save vs. extract + vault | 0 | L |
| 5 | Save vs. extract + vault | 0 | M (was L) |
| 4 | Federation | 0, 3, hub built | XL |
#6 is shippable on its own and touches nothing else — good first commit on `develop`. #4 should stay last: it's the only item that's hard to reverse once other people are running instances against your hub.
## Decisions — settled 2026-09-01
1. **#5 recall UX** — inline cannot send an album. For multi-file vault items, the inline result sends the **link to the original post** where one exists; otherwise it tells the user to open the bot and pull the full album there. Single-file items send directly as today.
1. **#5 recall UX** — settled further during implementation: the vault is **forward-based and retrievable only inside the bot**. Inline search does not surface vault posts at all; the save confirmation says so.
2. **#4 hub location** — **separate repository.** Channel name and hub hosting are the owner's call, not decided here.
3. **#6 video_note** — **store as `video`**, accepting the loss of round presentation, so round videos stay inline-searchable. See the risk note below.
4. **#4 architecture** — **shared federation channel accepted.** Media rides the channel, metadata rides the hub API.
+18
View File
@@ -59,10 +59,28 @@ docker compose up -d --build
| دستور | توضیح |
|---|---|
| `/list` | نمایش همه رسانه‌های ذخیره‌شده |
| `/vault [search]` | بازیابی پستی که کامل ذخیره کرده‌اید |
| `/review` | بررسی یک‌به‌یک ارسال‌های در انتظار تأیید (فقط ادمین) |
| `/delete <id>` | حذف یک مورد (ID را از `/list` بگیرید) |
| `/cancel` | لغو عملیات جاری |
### گاوصندوق — ذخیره کامل یک پست
اگر رسانه‌ای را مستقیم برای ربات بفرستید، مثل همیشه رفتار می‌کند: رسانه را جدا می‌کند و کلیدواژه می‌خواهد.
اما اگر پستی را **فوروارد** کنید، ربات می‌پرسد چه می‌خواهید:
- **ذخیره کل پست** — پست را دست‌نخورده با کپشن در گاوصندوق نگه می‌دارد. آلبوم هم آلبوم می‌ماند.
- **جدا کردن رسانه** — همان رفتار قبلی؛ رسانه به کتابخانه قابل جستجو اضافه می‌شود.
با `/vault [search]` پست‌های ذخیره‌شده را پیدا کنید؛ جستجو هم روی کلیدواژه‌ها و هم روی متن کپشن اصلی انجام می‌شود. کنار هر نتیجه یک دکمه برای فرستادن دوباره پست و یک دکمه برای حذف آن هست.
> **پست‌های گاوصندوق فقط داخل خود ربات برمی‌گردند.** حالت inline تلگرام نمی‌تواند پیام موجود را فوروارد کند، پس `@yourbotusername keyword` به گاوصندوق دسترسی ندارد. پست را داخل ربات بگیرید و بعد هرجا خواستید فوروارد کنید.
گاوصندوق برای هر کاربر جداگانه و خصوصی است: در جدولی جدا از کتابخانه میم‌ها ذخیره می‌شود، هر جستجو محدود به صاحب آن است، و هیچ‌چیزِ آن بررسی یا به اشتراک گذاشته نمی‌شود.
ذخیره‌سازی به‌جای کپی رسانه، اشاره‌ای به پیام موجود در چت شما با ربات را نگه می‌دارد. اگر تاریخچه چتتان با ربات را پاک کنید، آن پست‌ها دیگر قابل بازیابی نیستند — ربات در این حالت به شما اطلاع می‌دهد تا دوباره فوروارد کنید.
### عمومی‌کردن ربات
با تنظیم `PUBLIC_MODE=true` ربات به‌جای فهرست `ALLOWED_USERS`، به هر کسی که شرط عضویت را داشته باشد اجازه دسترسی می‌دهد:
+18
View File
@@ -65,11 +65,29 @@ Telegram suggests these commands as you type `/`, with a short description besid
| `/start` | Start the bot and show usage instructions |
| `/menu` | Show the available commands |
| `/list` | Show all saved media |
| `/vault [search]` | Retrieve a whole post you saved |
| `/review` | Review pending submissions one by one (admin only) |
| `/edit [search]` | Select a meme and edit its keywords (admin only) |
| `/delete <id>` | Remove an entry (get ID from `/list`; admin only) |
| `/cancel` | Cancel current add operation |
### The vault — saving whole posts
Send the bot media directly and it behaves as it always has: it extracts the media and asks for keywords.
**Forward** a post to it instead and it asks what you want:
- **Save whole post** — keeps the post intact in your vault, caption and all. An album stays an album.
- **Extract media** — the existing behaviour, adding the media to your searchable library.
Retrieve saved posts with `/vault [search]`, which matches your keywords and the original caption. Each result has a button to forward it back to you, and one to remove it.
> **Vault posts only come back inside this bot.** Telegram's inline mode cannot forward an existing message, so `@yourbotusername keyword` does not reach the vault. Retrieve the post in the bot chat, then forward it wherever you want.
The vault is per-user and private: it is stored separately from the meme library, every lookup is scoped to its owner, and nothing in it is ever moderated or shared.
Saving stores a pointer to the message in your chat with the bot rather than a copy of the media. If you clear your chat history with the bot, saved posts can no longer be retrieved — the bot tells you when that happens so you can forward them again.
### Making the bot public
Set `PUBLIC_MODE=true` to open the bot to anyone who meets a membership requirement, instead of the `ALLOWED_USERS` list:
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.4.0
+288 -26
View File
@@ -79,6 +79,7 @@ USER_COMMANDS = [
BotCommand(command="start", description="Start the bot"),
BotCommand(command="menu", description="Show available commands"),
BotCommand(command="list", description="Browse saved memes"),
BotCommand(command="vault", description="Retrieve a saved post"),
BotCommand(command="cancel", description="Cancel current operation"),
]
ADMIN_COMMANDS = [
@@ -238,12 +239,17 @@ class EditMedia(StatesGroup):
waiting_for_new_keywords = State()
class VaultSave(StatesGroup):
waiting_for_keywords = State()
def menu_text(user_id: int) -> str:
if is_admin(user_id):
return (
"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"
"/vault [search] — retrieve a whole post you saved\n"
"/review — review pending submissions\n"
"/edit [search] — edit keywords of a meme\n"
"/delete &lt;id&gt; — remove a meme by ID\n"
@@ -257,7 +263,8 @@ def menu_text(user_id: int) -> str:
)
return (
"Use me inline: type <code>@botname keyword</code> in any chat.\n\n"
"/list — browse all saved memes"
"/list — browse all saved memes\n"
"/vault [search] — retrieve a whole post you saved"
)
@@ -303,23 +310,28 @@ 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, status: str = "approved"):
async def start_keyword_prompt(chat_id: int, state: FSMContext, items: list[dict],
status: str = "approved") -> None:
"""Begin collecting keywords for one or more media items. Everything in
`items` ends up sharing the keywords, which is what makes extracting a
whole album a single interaction."""
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"
await state.update_data(items=items, keywords=[], status=status)
count = f"{len(items)} items" if len(items) > 1 else "it"
await bot.send_message(
chat_id,
f"Got {count}! Now send keywords — one per message or comma-separated.\n"
"Send /done when finished.",
parse_mode="HTML",
)
async def _receive_media(msg: Message, state: FSMContext, file_id: str, file_unique_id: str,
media_type: str, status: str = "approved"):
item = {"file_id": file_id, "file_unique_id": file_unique_id, "media_type": media_type}
await start_keyword_prompt(msg.chat.id, state, [item], status)
# Ordered: Telegram sets `document` alongside `animation` for backward
# compatibility, so animations must be matched before documents or every GIF
# would be filed as a file. Round videos are stored as plain "video" so they
@@ -350,7 +362,7 @@ def extract_media(msg: Message) -> tuple[str, str, str] | None:
return None
@dp.message(MEDIA_FILTER, StateFilter(None))
@dp.message(MEDIA_FILTER, StateFilter(None), F.forward_origin.is_(None))
async def on_media(msg: Message, state: FSMContext):
user_id = msg.from_user.id
if is_admin(user_id):
@@ -376,27 +388,32 @@ async def cmd_done(msg: Message, state: FSMContext):
await msg.answer("You haven't added any keywords yet. Send some, then /done.")
return
status = data.get("status", "approved")
items = data.get("items", [])
for item in items:
await db.add_media(
msg.from_user.id,
data["file_id"],
data["file_unique_id"],
data["media_type"],
item["file_id"],
item["file_unique_id"],
item["media_type"],
keywords,
status=status,
)
await state.clear()
saved = f"{len(items)} items" if len(items) > 1 else "it"
if status == "pending":
await msg.answer(
f"Submitted with keywords: <b>{', '.join(keywords)}</b>\n"
f"Submitted {saved} 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")
await msg.answer(f"Saved {saved} with keywords: <b>{', '.join(keywords)}</b>",
parse_mode="HTML")
@dp.message(AddMedia.waiting_for_keywords, F.text)
async def on_keyword(msg: Message, state: FSMContext):
async def accumulate_keywords(msg: Message, state: FSMContext) -> None:
"""Shared by adding, editing and vault saves: merge in whatever was typed
and echo the running list back."""
new_kws = [k.strip().lower() for k in msg.text.split(",") if k.strip()]
if not new_kws:
return
@@ -407,6 +424,11 @@ async def on_keyword(msg: Message, state: FSMContext):
await msg.reply(f"Keywords so far: <b>{', '.join(merged)}</b>\nSend more or /done.", parse_mode="HTML")
@dp.message(AddMedia.waiting_for_keywords, F.text)
async def on_keyword(msg: Message, state: FSMContext):
await accumulate_keywords(msg, state)
@dp.message(AddMedia.waiting_for_keywords, MEDIA_FILTER)
async def on_media_during_keywords(msg: Message):
await msg.reply("Still waiting for keywords. Send /done to save the current item, or /cancel to discard.")
@@ -560,14 +582,254 @@ async def cmd_edit_cancel(msg: Message, state: FSMContext):
@dp.message(EditMedia.waiting_for_new_keywords, F.text)
async def on_edit_keyword(msg: Message, state: FSMContext):
new_kws = [k.strip().lower() for k in msg.text.split(",") if k.strip()]
if not new_kws:
await accumulate_keywords(msg, state)
# ── forwards: save whole, or extract the media ───────────────────────────────
#
# A forwarded album arrives as several separate messages sharing a
# media_group_id, with no signal that the last one has landed, so they are
# buffered briefly and treated as one post.
ALBUM_DEBOUNCE = 1.0
PENDING_FORWARD_LIMIT = 200
_album_buffers: dict[str, dict] = {}
_pending_forwards: dict[str, dict] = {}
def _new_forward_record(msg: Message) -> dict:
return {
"user_id": msg.from_user.id,
"chat_id": msg.chat.id,
"message_ids": [],
"caption": "",
"media": [],
}
def _absorb(record: dict, msg: Message) -> None:
record["message_ids"].append(msg.message_id)
# In an album only one message carries the caption.
text = msg.caption or msg.text or ""
if text and not record["caption"]:
record["caption"] = text
found = extract_media(msg)
if found is not None:
media_type, file_id, file_unique_id = found
record["media"].append(
{"media_type": media_type, "file_id": file_id, "file_unique_id": file_unique_id}
)
async def prompt_forward_choice(record: dict) -> None:
token = uuid.uuid4().hex[:12]
# Bounded so a long-running bot cannot accumulate abandoned prompts.
while len(_pending_forwards) >= PENDING_FORWARD_LIMIT:
_pending_forwards.pop(next(iter(_pending_forwards)))
_pending_forwards[token] = record
record["message_ids"].sort()
parts = [f"{len(record['message_ids'])} message(s)"]
if record["media"]:
parts.append(f"{len(record['media'])} media")
summary = ", ".join(parts)
row = [InlineKeyboardButton(text="📥 Save whole post", callback_data=f"fwd:save:{token}")]
if record["media"]:
row.append(InlineKeyboardButton(text="✂️ Extract media", callback_data=f"fwd:extract:{token}"))
await bot.send_message(
record["chat_id"],
f"Forwarded post received ({summary}).\n\n"
"<b>Save whole post</b> keeps it intact in your vault — retrievable only "
"inside this bot, with /vault.\n"
"<b>Extract media</b> adds the media to your searchable library as usual.",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[row]),
)
async def _flush_album(group_id: str) -> None:
"""Wait until an album has stopped arriving, then prompt once for all of it."""
while True:
record = _album_buffers.get(group_id)
if record is None:
return
remaining = record["deadline"] - time.monotonic()
if remaining <= 0:
break
await asyncio.sleep(remaining)
record = _album_buffers.pop(group_id, None)
if record is not None:
await prompt_forward_choice(record)
@dp.message(F.forward_origin, StateFilter(None))
async def on_forwarded(msg: Message, state: FSMContext):
if not await has_access(msg.from_user.id):
await deny(msg)
return
group_id = msg.media_group_id
if group_id is None:
record = _new_forward_record(msg)
_absorb(record, msg)
await prompt_forward_choice(record)
return
record = _album_buffers.get(group_id)
if record is None:
record = _new_forward_record(msg)
_album_buffers[group_id] = record
record["deadline"] = time.monotonic() + ALBUM_DEBOUNCE
asyncio.create_task(_flush_album(group_id))
_absorb(record, msg)
record["deadline"] = time.monotonic() + ALBUM_DEBOUNCE
@dp.callback_query(F.data.startswith("fwd:"))
async def on_forward_choice(callback: CallbackQuery, state: FSMContext):
_, action, token = callback.data.split(":", 2)
record = _pending_forwards.pop(token, None)
if record is None:
await callback.answer("That prompt expired — forward the post again.", show_alert=True)
return
if record["user_id"] != callback.from_user.id:
await callback.answer("Not yours.", show_alert=True)
return
await callback.message.edit_reply_markup(reply_markup=None)
if action == "extract":
if not await has_access(callback.from_user.id):
await callback.answer("Not allowed.", show_alert=True)
return
status = "approved" if is_admin(callback.from_user.id) else "pending"
await start_keyword_prompt(record["chat_id"], state, record["media"], status)
await callback.answer()
return
await state.set_state(VaultSave.waiting_for_keywords)
await state.update_data(
chat_id=record["chat_id"],
message_ids=record["message_ids"],
caption=record["caption"],
keywords=[],
)
hint = "Send keywords, then /done." if record["caption"] else \
"Send keywords, then /done. This post has no caption, so keywords are how you will find it."
await callback.message.answer(hint)
await callback.answer()
@dp.message(VaultSave.waiting_for_keywords, Command("done"))
async def cmd_vault_done(msg: Message, state: FSMContext):
data = await state.get_data()
existing: list[str] = data.get("keywords", [])
merged = existing + [k for k in new_kws if k not in existing]
await state.update_data(keywords=merged)
await msg.reply(f"Keywords so far: <b>{', '.join(merged)}</b>\nSend more or /done.", parse_mode="HTML")
keywords: list[str] = data.get("keywords", [])
caption: str = data.get("caption", "")
if not keywords and not caption:
await msg.answer("Add at least one keyword — there is no caption to find this by.")
return
item_id = await db.add_vault_item(
msg.from_user.id, data["chat_id"], data["message_ids"], caption, keywords
)
await state.clear()
await msg.answer(
f"Saved to your vault as <b>#{item_id}</b>.\n\n"
"Vault posts come back whole, exactly as forwarded — but only here in "
"this chat, with /vault. Inline search does not reach them.",
parse_mode="HTML",
)
@dp.message(VaultSave.waiting_for_keywords, F.text)
async def on_vault_keyword(msg: Message, state: FSMContext):
await accumulate_keywords(msg, state)
# ── /vault ───────────────────────────────────────────────────────────────────
def vault_label(item: dict) -> str:
text = item["keywords"].replace(",", ", ") or item["caption"]
text = " ".join(text.split())
if len(text) > 40:
text = text[:39] + "…"
count = len(db.unpack_message_ids(item["message_ids"]))
suffix = f" ({count})" if count > 1 else ""
return f"#{item['id']} {text or 'untitled'}{suffix}"
@dp.message(Command("vault"))
async def cmd_vault(msg: Message):
if not await has_access(msg.from_user.id):
await deny(msg)
return
parts = msg.text.split(maxsplit=1)
query = parts[1].strip() if len(parts) > 1 else ""
items = await db.search_vault(msg.from_user.id, query)
if not items:
if query:
await msg.answer(f"Nothing in your vault matching '{query}'.")
else:
await msg.answer(
"Your vault is empty. Forward a post to me and choose "
"<b>Save whole post</b> to put it here.",
parse_mode="HTML",
)
return
rows = [
[
InlineKeyboardButton(text=vault_label(item), callback_data=f"vault:get:{item['id']}"),
InlineKeyboardButton(text="🗑", callback_data=f"vault:del:{item['id']}"),
]
for item in items
]
total = await db.count_vault(msg.from_user.id)
header = f"Showing {len(items)} of {total} saved posts." if not query else \
f"{len(items)} match '{query}'."
await msg.answer(header, reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
@dp.callback_query(F.data.startswith("vault:"))
async def on_vault_action(callback: CallbackQuery):
_, action, raw_id = callback.data.split(":", 2)
# Scoped by owner, so a guessed id cannot reach someone else's vault.
item = await db.get_vault_item(callback.from_user.id, int(raw_id))
if item is None:
await callback.answer("Not found.", show_alert=True)
return
if action == "del":
await db.delete_vault_item(callback.from_user.id, item["id"])
await callback.answer("Removed from vault.")
await callback.message.answer(f"Removed #{item['id']} from your vault.")
return
message_ids = db.unpack_message_ids(item["message_ids"])
try:
# forwardMessages keeps an album together and preserves the original
# "forwarded from" header. Telegram requires ascending ids.
await bot.forward_messages(
chat_id=callback.message.chat.id,
from_chat_id=item["chat_id"],
message_ids=sorted(message_ids),
)
await callback.answer()
except TelegramAPIError:
log.exception("could not forward vault item %s", item["id"])
await callback.answer("Could not retrieve that post.", show_alert=True)
await callback.message.answer(
f"#{item['id']} could not be retrieved — the original messages may have "
"been deleted from this chat. Forward the post again to re-save it."
)
# ── /review ──────────────────────────────────────────────────────────────────
+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]
+5 -2
View File
@@ -47,12 +47,15 @@ async def test_legacy_upgrade(tmp: str):
db = load_db(path)
await db.init_db()
assert user_version(path) == 3
# Pinned to the module so adding a migration does not require editing this.
assert user_version(path) == db.SCHEMA_VERSION
rows = sorted((r["id"], r["keywords"]) for r in await db.search_media(""))
assert rows == [(7, "cat,funny"), (9, "dog")], f"rows not preserved: {rows}"
with sqlite3.connect(path) as c:
statuses = {r[0] for r in c.execute("SELECT status FROM media")}
tables = {r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")}
assert statuses == {"approved"}, f"upgraded rows must stay live: {statuses}"
assert "vault_items" in tables, "upgrading should create the vault table too"
print(" legacy v1 database upgrades with ids, keywords and visibility intact")
@@ -91,7 +94,7 @@ async def test_fresh_and_idempotent(tmp: str):
with sqlite3.connect(path) as c:
sql = c.execute("SELECT sql FROM sqlite_master WHERE name='media'").fetchone()[0]
assert "UNIQUE(owner_id, file_unique_id)" in sql
assert user_version(path) == 3
assert user_version(path) == db.SCHEMA_VERSION
await db.init_db()
await db.init_db()
+176
View File
@@ -0,0 +1,176 @@
"""Vault tests. Run with: python test_vault.py
The vault stores pointers to messages the bot already holds, so the things
worth pinning down are that albums are grouped into one post, that retrieval
asks Telegram for the right message ids, and that one user can never reach
another user's saved posts.
"""
import asyncio
import datetime
import importlib
import logging
import os
import sys
import tempfile
os.environ.setdefault("BOT_TOKEN", "123456789:AAHtestTokenPlaceholder_ABCDEFGHIJKLMNOP")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from aiogram.types import Chat, Message, PhotoSize, User
logging.disable(logging.CRITICAL)
CHAT = Chat(id=500, type="private")
USER = User(id=111, is_bot=False, first_name="Owner")
NOW = datetime.datetime.now()
def load(path: str, **env):
for key in ("PUBLIC_MODE", "REQUIRED_CHATS", "ALLOWED_USERS", "ADMIN_USERS"):
os.environ.pop(key, None)
os.environ["DB_PATH"] = path
os.environ.update(env)
import db, bot
importlib.reload(db)
B = importlib.reload(bot)
B.db = db
return db, B
def photo_msg(message_id: int, caption: str | None = None, group: str | None = None) -> Message:
photo = PhotoSize(file_id=f"F{message_id}", file_unique_id=f"U{message_id}", width=1, height=1)
return Message(message_id=message_id, date=NOW, chat=CHAT, from_user=USER,
photo=[photo], caption=caption, media_group_id=group)
# ── database ─────────────────────────────────────────────────────────────────
async def test_roundtrip(db):
item_id = await db.add_vault_item(111, 500, [12, 10, 11], "A trip to the sea", ["sea"])
item = await db.get_vault_item(111, item_id)
assert db.unpack_message_ids(item["message_ids"]) == [12, 10, 11]
assert item["caption"] == "A trip to the sea"
print(" a saved post round-trips with its message ids and caption")
async def test_search_by_keyword_and_caption(db):
await db.add_vault_item(111, 500, [20], "Recipe for lasagne", ["food"])
assert len(await db.search_vault(111, "food")) == 1, "keyword search"
assert len(await db.search_vault(111, "lasagne")) == 1, "caption search"
assert len(await db.search_vault(111, "lasagne")) == 1
assert await db.search_vault(111, "nonsense") == []
print(" posts are findable by keyword and by words in the original caption")
async def test_vault_is_private_to_its_owner(db):
mine = await db.add_vault_item(111, 500, [30], "private note", ["secret"])
assert await db.get_vault_item(222, mine) is None, "another user fetched my post"
assert await db.search_vault(222, "secret") == [], "another user searched my vault"
assert await db.delete_vault_item(222, mine) is False, "another user deleted my post"
assert await db.get_vault_item(111, mine) is not None, "owner lost their own post"
print(" one user cannot read, search or delete another user's vault")
async def test_delete(db):
item_id = await db.add_vault_item(111, 500, [40], "", ["bye"])
assert await db.delete_vault_item(111, item_id) is True
assert await db.get_vault_item(111, item_id) is None
assert await db.delete_vault_item(111, item_id) is False
print(" deleting removes the post and is safe to repeat")
# ── album grouping ───────────────────────────────────────────────────────────
async def test_album_becomes_one_post(B):
B.ALBUM_DEBOUNCE = 0.05
B._album_buffers.clear()
B._pending_forwards.clear()
sent: list = []
async def send_message(chat_id, text, **kw):
sent.append(text)
B.bot.send_message = send_message
# Telegram delivers an album as separate messages; only one has the caption.
for msg in (photo_msg(10, caption="beach day", group="g1"),
photo_msg(11, group="g1"),
photo_msg(12, group="g1")):
await B.on_forwarded(msg, None)
await asyncio.sleep(0.3)
assert len(sent) == 1, f"an album should prompt once, got {len(sent)} prompts"
assert len(B._pending_forwards) == 1
record = next(iter(B._pending_forwards.values()))
assert record["message_ids"] == [10, 11, 12]
assert record["caption"] == "beach day", "the one caption in the album was lost"
assert len(record["media"]) == 3, "every item in the album should be extractable"
print(" an album is buffered into one post, keeping its caption and all media")
async def test_single_forward_prompts_immediately(B):
B._album_buffers.clear()
B._pending_forwards.clear()
sent: list = []
async def send_message(chat_id, text, **kw):
sent.append(text)
B.bot.send_message = send_message
await B.on_forwarded(photo_msg(20, caption="just one"), None)
assert len(sent) == 1, "a lone forward should not wait for an album"
record = next(iter(B._pending_forwards.values()))
assert record["message_ids"] == [20]
print(" a single forwarded post prompts straight away")
async def test_pending_prompts_are_bounded(B):
B._pending_forwards.clear()
B.bot.send_message = lambda *a, **kw: asyncio.sleep(0)
for i in range(B.PENDING_FORWARD_LIMIT + 25):
await B.prompt_forward_choice(
{"user_id": 1, "chat_id": 500, "message_ids": [i], "caption": "", "media": []}
)
assert len(B._pending_forwards) <= B.PENDING_FORWARD_LIMIT
print(" abandoned prompts cannot grow without bound")
async def test_retrieval_forwards_ascending_ids(db, B):
item_id = await db.add_vault_item(111, 500, [12, 10, 11], "out of order", ["x"])
forwarded: dict = {}
async def forward_messages(chat_id, from_chat_id, message_ids):
forwarded.update(chat_id=chat_id, from_chat_id=from_chat_id, message_ids=message_ids)
B.bot.forward_messages = forward_messages
class Cb:
data = f"vault:get:{item_id}"
from_user = type("U", (), {"id": 111})()
message = type("M", (), {"chat": CHAT})()
async def answer(self, *a, **kw): pass
await B.on_vault_action(Cb())
# Telegram requires strictly increasing ids, and the album must stay whole.
assert forwarded["message_ids"] == [10, 11, 12], forwarded
assert forwarded["from_chat_id"] == 500
print(" retrieval forwards the whole post with ids in ascending order")
async def main():
with tempfile.TemporaryDirectory() as tmp:
db, B = load(os.path.join(tmp, "vault.db"), ADMIN_USERS="111")
await db.init_db()
for test in (test_roundtrip, test_search_by_keyword_and_caption,
test_vault_is_private_to_its_owner, test_delete):
await test(db)
for test in (test_album_becomes_one_post, test_single_forward_prompts_immediately,
test_pending_prompts_are_bounded):
await test(B)
await test_retrieval_forwards_ascending_ids(db, B)
print("\nAll vault tests passed.")
if __name__ == "__main__":
asyncio.run(main())