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:
@@ -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 <id> — 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
|
||||
|
||||
Reference in New Issue
Block a user