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>
Completes TODO #2. PUBLIC_MODE swaps the ALLOWED_USERS allowlist for a
membership check against REQUIRED_CHATS, with REQUIRE_MODE choosing
between needing every chat or just one.
- Stop is_admin() falling back to "everyone is an admin" when
ADMIN_USERS is unset once PUBLIC_MODE is on. That fallback is the
original single-owner convenience, but under public mode it handed
moderation, /edit and /delete to anyone who found the bot. Public
mode now grants admin to nobody until ADMIN_USERS is set, and says
so at startup. Private deployments are unaffected.
- Cache membership answers: inline search queries on every keystroke,
so an uncached check meant several API calls per typed character.
Grants last five minutes, refusals thirty seconds, so a user who has
just joined is not locked out for the full window.
- Fail closed when the membership check errors, and validate
REQUIRED_CHATS at startup so a chat the bot cannot read is reported
rather than silently refusing every user.
- Refused users get a join prompt with real links instead of silence;
inline shows a tappable bar that opens the bot and explains why.
A private bot stays silent as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Completes TODO #6. The bot accepted photo, animation, video, voice and
sticker; music files, documents and round videos were silently dropped.
- Replace the five near-identical media handlers with one dispatch table.
Animations are matched before documents because Telegram populates
both fields for a GIF, so the old ordering would have filed every GIF
as a document once document support existed.
- Store video notes as "video". Telegram has no cached inline result for
video notes, so this is what keeps round videos searchable; it costs
the round presentation on playback.
- Build inline results from a table keyed by media_type, and cover audio
and document. A test asserts every storable type has a builder, so a
new media type cannot be added without a way to return it.
- Reuse one MEDIA_FILTER for both the idle and mid-keyword handlers;
the second list had already drifted from the first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VERSION had been left at 1.0.0 while the registry already carried 1.0.1, so
push.sh (which defaults to the VERSION file) would have rebuilt an existing
tag. This realigns the file with what is actually published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Split docker-compose into build (docker-compose.build.yaml) and deploy (docker-compose.yaml pulls from registry)
- Add push.sh for building and pushing versioned images to git.goyban.com/goyban/bot_meme
- Add ADMIN_USERS env var; admins can add/edit/delete, users can search
- Add /edit command with inline keyboard to update meme keywords
- Add sticker support (save and serve via inline)
- Switch /delete to use numeric row ID instead of file_unique_id
- Search is now global (shared pool) instead of per-user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>