Fix #102: confine the bot to the chosen topics of a forum - #150
Merged
Conversation
`/topics` opens an admin-only picker that allows or forbids the topic it was invoked in, or lifts the restriction altogether. A chat that never touched the setting works everywhere, and forbidding the last allowed topic goes back to that rather than leaving the bot with nowhere to speak. The allowlist is an id-keyed set in the existing `Chats.settings` jsonb, next to the chat language, so there is no migration. An object rather than an array because it merges and deletes in one statement and dedupes by key for free -- `jsonb - integer` would delete an array element by index, not by value. Every write is a single statement, so two admins tapping at once cannot clobber each other, and reads go through a TTL cache the writes refresh. Topics carry no names: the Bot API cannot be asked for one, and a list of `#42` labels says less than a count. So the picker speaks only about the topic it was opened in, plus how many topics the bot is confined to overall, and each topic is managed from inside itself. The gate sits at the very top of the dispatcher -- above even the ban gate, which is safe because it writes nothing for the sender -- so it covers every command rather than the game ones only. Its callback twin covers the buttons: a keyboard outlives the message it came with, so without it the game could still be played from a forbidden topic by tapping an older message. `/topics` is registered above the gate, which is the whole exemption and needs no special case inside it. The gate answers only for commands that are ours, matched against the same `bot_commands()` the menu is built from and against the `@username` Telegram appends: a group usually holds several bots, and answering for another one's command would be exactly the noise this feature exists to remove. It fails open on a database error -- an unreachable database must not lock a chat out. The daily-shrink broadcast now names the topic outright, which also fixes a failure that predates this: a forum whose General topic is closed refuses a message sent without one. Inline mode is out of scope (#76): an inline query carries no thread, and a message's topic is not derivable from its own id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LiWvKdbmK4148oZJ4DvrGC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #102.
/topicsopens an admin-only picker in a forum group. It shows whether the bot works in the topic it was invoked in, how many topics it is confined to overall, and offers the one action that makes sense there:Forbidding the last allowed topic goes back to "works everywhere" rather than leaving the bot with nowhere to speak.
A command sent to a forbidden topic gets a self-destructing notice; a button press there gets an alert.
Design notes
No migration. The allowlist is an id-keyed set in the existing
Chats.settingsjsonb, next to the chat language. An object rather than an array because it merges and deletes in one statement and dedupes by key for free — note thatjsonb - integerdeletes an array element by index, not by value. Every write is a single statement, so two admins tapping at once can't clobber each other; reads go through a TTL cache (CHAT_TOPICS_CACHE_TIME_SECS) that the writes refresh.Topics carry no names. The Bot API can't be asked for one, and a list of
#42labels says less than a count — so nothing is stored or shown for them, and each topic is managed from inside itself.Gate placement is load-bearing. It sits at the very top of the dispatcher, above even the ban gate, which is safe because it writes nothing for the sender — so it covers every command, not just the game ones. Its callback twin covers the buttons: a keyboard outlives the message it came with, so without it the game could still be played from a forbidden topic by tapping an older message.
/topicsis registered above the gate — that placement is the whole exemption and needs no special case inside it, and a test pins the ordering down.Only our commands are answered for. Matched against the same
bot_commands()the menu is built from, plus the@usernameTelegram appends. A group usually holds several bots, and answering for another one's command would be exactly the noise this feature exists to remove. The gate fails open on a database error — an unreachable database must not lock a chat out.Bonus fix. The daily-shrink broadcast now names the topic outright, which also fixes a failure that predates this: a forum whose General topic is closed refuses a message sent without one.
Out of scope
Inline mode (#76): an inline query carries no thread, and a message's topic isn't derivable from its own id — the
inline_message_iddecoded intghack.rsholds only(dc_id, peer, message_id, access_hash).Testing
152 tests pass, clippy clean. New coverage: the jsonb round-trips (including that the topics and the chat language never wipe each other, asserted after every write), the gate predicates for messages and callbacks, and the picker's button set per state — asserted on payloads rather than labels, so it doesn't depend on the locale.
Manually checked in a real forum group.
Deployment
CHAT_TOPICS_CACHE_TIME_SECSis new and optional (3600 by default) — it needs theenvironment:line in the server-configsdocker-compose.yml, but no.env.sopsentry since the default is fine. The Grafana dashboard should also learn aboutcommand_topics_usage_total,chat_topics_get_totalandtopic_restricted_total.🤖 Generated with Claude Code
https://claude.ai/code/session_01LiWvKdbmK4148oZJ4DvrGC