Record what may be shared, ahead of federation

The bot-side half of TODO #4. Nothing federates yet; this establishes
what would be eligible to, and proves the boundary before there is a
hub that could leak across it.

- Schema v5 adds visibility, defaulting to private. Upgrading an
  existing library therefore opts nothing into sharing, which a test
  on the legacy upgrade path pins down.
- list_syncable() is the only function that will ever feed a hub, and
  it requires both public and approved. Tests force rows into states
  set_visibility refuses, so the filter is what protects the boundary
  rather than the caller being careful.
- set_visibility refuses anything not approved: media still in the
  moderation queue cannot be marked shared.
- /share lists memes with their current state and asks before making
  one public, since un-sharing stops future copies but cannot recall
  those already taken. The wording describes what sharing will mean
  once a hub exists, because by then warning is too late.
- /list shows the same markers.

Vault posts have no path into the sync set at all; being in a separate
table is what guarantees it, and there is a test saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 22:09:37 +00:00
parent a35abb0541
commit 0732f2c34b
7 changed files with 272 additions and 7 deletions
+102 -1
View File
@@ -86,6 +86,7 @@ ADMIN_COMMANDS = [
*USER_COMMANDS,
BotCommand(command="review", description="Review pending submissions"),
BotCommand(command="edit", description="Edit meme keywords"),
BotCommand(command="share", description="Choose what is shared"),
BotCommand(command="delete", description="Remove a meme by ID"),
]
@@ -252,6 +253,7 @@ def menu_text(user_id: int) -> str:
"/vault [search] — retrieve a whole post you saved\n"
"/review — review pending submissions\n"
"/edit [search] — edit keywords of a meme\n"
"/share [search] — mark a meme shared or private\n"
"/delete &lt;id&gt; — remove a meme by ID\n"
"/cancel — cancel current operation"
)
@@ -447,7 +449,10 @@ async def list_page(page: int) -> tuple[str, InlineKeyboardMarkup | None]:
total_pages = (total + LIST_PAGE_SIZE - 1) // LIST_PAGE_SIZE
page = max(0, min(page, total_pages - 1))
items = await db.list_media(page, LIST_PAGE_SIZE)
lines = [f"<code>{item['id']}</code> [{item['media_type']}] — {item['keywords']}" for item in items]
lines = [
f"{visibility_icon(item)} <code>{item['id']}</code> [{item['media_type']}] — {item['keywords']}"
for item in items
]
text = f"Page {page + 1} of {total_pages}\n" + "\n".join(lines)
buttons = []
@@ -832,6 +837,102 @@ async def on_vault_action(callback: CallbackQuery):
)
# ── /share ───────────────────────────────────────────────────────────────────
#
# Marking media public only records intent: nothing leaves this bot until a
# federation hub is configured. The confirmation is worded for what sharing
# will mean once one is, because by then it is too late to warn.
def visibility_icon(item: dict) -> str:
return "🌐" if item.get("visibility") == "public" else "🔒"
@dp.message(Command("share"))
async def cmd_share(msg: Message):
if not is_admin(msg.from_user.id):
await msg.answer("Only admins can change what is shared.")
return
parts = msg.text.split(maxsplit=1)
query = parts[1].strip() if len(parts) > 1 else ""
items = await db.search_media(query)
if not items:
no_match = f" matching '{query}'" if query else ""
await msg.answer(f"No memes found{no_match}.")
return
rows = [
[InlineKeyboardButton(
text=f"{visibility_icon(item)} #{item['id']} {item['keywords'][:35]}",
callback_data=f"share:pick:{item['id']}",
)]
for item in items[:10]
]
public_count = await db.count_public()
await msg.answer(
f"🔒 private · 🌐 shared — tap to change.\n"
f"{public_count} of your memes are marked shared.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
@dp.callback_query(F.data.startswith("share:"))
async def on_share_action(callback: CallbackQuery):
if not is_admin(callback.from_user.id):
await callback.answer("Not allowed.", show_alert=True)
return
_, action, raw_id = callback.data.split(":", 2)
media_id = int(raw_id)
item = await db.get_media_by_id(media_id)
if item is None:
await callback.answer("Not found.", show_alert=True)
return
if action == "pick":
await callback.message.edit_reply_markup(reply_markup=None)
if item["visibility"] == "public":
await db.set_visibility(media_id, "private")
await callback.message.answer(
f"🔒 <b>#{media_id}</b> is private again. Any instance that already "
"took a copy keeps it — this stops future sharing, it does not recall "
"what has gone.",
parse_mode="HTML",
)
await callback.answer()
return
await callback.message.answer(
f"Share <b>#{media_id}</b> [{item['media_type']}]\n"
f"Keywords: <b>{item['keywords'].replace(',', ', ')}</b>\n\n"
"Shared media is copied by every other instance that syncs with it. "
"You can stop sharing later, but you cannot take back copies already "
"made. Nothing is shared until a federation hub is configured.",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="🌐 Share it", callback_data=f"share:yes:{media_id}"),
InlineKeyboardButton(text="Cancel", callback_data=f"share:no:{media_id}"),
]]),
)
await callback.answer()
return
await callback.message.edit_reply_markup(reply_markup=None)
if action == "no":
await callback.message.answer("Left private.")
await callback.answer()
return
shared = await db.set_visibility(media_id, "public")
if shared:
await callback.message.answer(f"🌐 <b>#{media_id}</b> is marked shared.", parse_mode="HTML")
else:
# set_visibility refuses anything not approved.
await callback.message.answer("Only approved media can be shared.")
await callback.answer()
# ── /review ──────────────────────────────────────────────────────────────────
# Sending a stored item back needs one call per media type. Stickers cannot