Skip to content

Let each chat choose how long the bot's messages live in it - #165

Merged
kozalosev merged 2 commits into
mainfrom
feature/self-destruction-configurable-per-chat
Aug 11, 2026
Merged

Let each chat choose how long the bot's messages live in it#165
kozalosev merged 2 commits into
mainfrom
feature/self-destruction-configurable-per-chat

Conversation

@kozalosev

Copy link
Copy Markdown
Owner

Closes #128.

The self-destruction of messages (#49, #127) was configured once, for the whole bot. This gives
every chat a say of its own — the follow-up the issue was created for, in case a chat turned out not
to want the mechanic, or to want it sooner.

What a chat gets

/cleanup, admins-only, a picker of two levels. The first lists the four message groups with what
each one costs in this chat; pressing a group opens the delays the bot suggests, plus the two
answers it always takes — keep them for good, or let the bot decide again. Those two are added by
the picker itself rather than by the configured list, so no list can leave a chat with a choice it
can't take back.

The mode stays the operator's: it is about the bot's rights rather than about taste, and
MSG_SELFDESTRUCT_MODE=DISABLED hides the command entirely.

How it is stored

A cleanup key in the Chats.settings jsonb, next to the chat language and the allowed topics —
no migration. A group is in one of three states, and all three are distinct: a number is the delay
the chat picked, a zero is the chat asking for that group to be kept, and an absent key is the chat
leaving the decision to the bot. The last two look alike until the operator changes his mind, which
is exactly when they part.

Two things worth a look in review

The rights warning. Choosing a non-zero delay is the one press that asks Telegram anything:
where the mode deletes commands the bot may not be allowed to, and then the answers would go while
the commands stayed. The press turns into a warning that has to be confirmed — a stronger one under
ONLY_WITH_COMMAND, where nothing at all would be deleted — carrying the chosen number through the
detour. The answer lands in the rights cache, which every later message reads.

Inline messages now obey the chat as well, where the chat can be named at all: two of the three
places that schedule a placeholder already hold one, and inline_chosen_handler works solely for
anchored groups. Since the settings can't be changed without adding the bot to the chat, those are
exactly the chats this can serve. An inline message can only be rewritten, never deleted, so the
chat gets a switch of its own rather than inheriting the delays quietly, and
MSG_SELFDESTRUCT_INLINE_GROUPS becomes the default for a chat that said nothing. Where no chat can
be named — a legacy group without an anchor, an id that encodes none — the bot's own settings apply,
which is the half of #76 that /topics runs into as well.

Inline messages are also stretched by the reading time now; its absence was mechanical rather than
considered.

Configuration

Two new variables, both optional and both defaulted:

MSG_SELFDESTRUCT_DELAY_OPTIONS_MINUTES=1,5,15,60,180  # what the picker offers
CHAT_CLEANUP_CACHE_TIME_SECONDS=3600                  # TTL of the per-chat settings cache

They are in .env.example, docker-compose.yml, the Dockerfile and the docs — but not in the
server-configs repository yet.

Along the way

  • MessageGroup moved out of config into src/domain/enums.rs: the new domain object had to name
    it, and domain never imports config. Re-exported, so no call site changed.
  • commands.support.disabled is gone — it was word for word the top-level errors.feature_disabled,
    which /support and /cleanup both use now.
  • While translating the new texts it turned out that the topics block and the two forbidden-topic
    errors had been left in English for it, fa, zh and zh-TW since Support enforcing of the bot use in a single chat topic only #102. They are translated too.

Verification

cargo build --all-targets, cargo clippy --all-targets clean; cargo test — 240 passed.

Covered by tests: the delay matrix (a chosen delay beats the bot's, a zero keeps, a value past the
cap is cut down, DISABLED beats everything), the inline matrix over the three states of the switch,
the option-list parsing, both keyboard levels and their wire format, and the jsonb round trip
alongside the language and the topics in the same column.

🤖 Generated with Claude Code

kozalosev and others added 2 commits August 11, 2026 23:28
The delays of the self-destruction were the operator's alone, and issue #49
was merged with that as an open question: if a chat turns out not to want the
mechanic, or to want it sooner, it had nothing to say about it.

Now it has /cleanup, an admins-only picker of two levels. The first lists the
four message groups with what each one costs in this chat; pressing a group
opens the delays the bot suggests, plus the two answers it always takes —
keep them for good, or let the bot decide again. Those two are added by the
picker itself rather than by the list, so no configuration can leave a chat
with a choice it can't take back.

The choice is a number of minutes, stored under a `cleanup` key of the
`Chats.settings` jsonb next to the chat language and the allowed topics. A
group is therefore in one of three states, and all three are distinct: a
number is the delay the chat picked, a zero is the chat asking for that group
to be kept, and an absent key is the chat leaving the decision to the bot. The
last two look alike until the operator changes his mind, which is exactly when
they part. No migration: the column has been there since migration 25.

The numbers on offer are MSG_SELFDESTRUCT_DELAY_OPTIONS_MINUTES, sorted and
deduped, with zeroes and anything past the 47-hour limit dropped as it is
read. A stored value that is no longer in the list still applies — the list is
a suggestion for the next press, not a rule about what may already be stored —
which is why the cap is applied on the way out and not only on the way in. The
list also counts towards `enabled()`, the worker's spawn gate, since a chat
can now be the only reason a row is ever written; with nothing offered, a chat
can only keep messages, and the gate says so.

Choosing a non-zero delay is the one press that asks Telegram anything. Where
the mode deletes commands the bot may not be allowed to, and then the answers
would go while the commands stayed, so the press turns into a warning that has
to be confirmed — a stronger one under ONLY_WITH_COMMAND, where nothing at all
would be deleted — carrying the chosen number through the detour. The answer
lands in the rights cache, which every later message reads.
`SelfDestructionService::may_delete_here` is that unconditional check, next to
the mode-dependent `may_delete_commands` the answering path uses.

The setting is cached in this process, like the chat language and the allowed
topics, not in the Redis cache: that one is flag-shaped and is never a source
of truth. Inline messages keep the bot's own settings, since an InlineQuery
carries no chat to look the setting up by — the same half of #76 that /topics
runs into.

Two things came along on the way.

`MessageGroup` moved out of `config` into `src/domain/enums.rs`: the new domain
object had to name it, and domain never imports config. `config` re-exports it,
so every call site stays as it was.

And `commands.support.disabled` is gone. It was word for word the top-level
`errors.feature_disabled`, which is what /support and /cleanup both use now.
While translating the new block it turned out that the `topics` one and the two
forbidden-topic errors had been left in English for it, fa, zh and zh-TW since
#102; they are translated too.

The two new variables are in .env.example, docker-compose.yml, the Dockerfile
and the docs, but not in the server-configs repository — that one is separate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/cleanup governed only what the bot sends into a chat. Inline answers went
past it on the operator's numbers, because an inline update names no chat.

That last part is only half true, and the testing of #128 is what made it
worth checking. Two of the three places that schedule a placeholder already
hold a chat: `inline_chosen_handler` works solely for anchored groups — it
filters on a row carrying both the id and the instance — and
`inline_callback_handler` resolves one for the command itself. Only the battle
offer has to decode the id out of the `inline_message_id`, and only where the
chats are merged. Which is the point: the settings can't be changed without
adding the bot to the chat anyway, so the chats this can serve are exactly the
anchored ones.

An inline message can never be deleted, only rewritten into the placeholder,
and that is a different promise from "this will be gone in five minutes". So
the chat gets a switch of its own rather than quietly inheriting the delays —
`MSG_SELFDESTRUCT_INLINE_GROUPS` becomes the default for a chat that said
nothing instead of the last word for everyone.

The flag lives in the `cleanup` object beside the groups, under the one key a
group can never be called. It is a tri-state like the delays: absent means the
operator's list decides which groups are touched, `true` means every group the
chat cleans up, `false` means none. `inline_delay_for_chat` is where the two
meet, and the delay is the chat's either way. Where no chat can be named — a
legacy group without an anchor, an id that encodes none — the bot's own
settings apply, which is the half of #76 that /topics runs into as well.

Inline messages are also stretched by the reading time now. Its absence was
mechanical rather than considered: `schedule` is handed a `Message` and reads
`sent.text()`, while `schedule_inline` was handed only an id. The text is right
there at both call sites, one line above the request that sends it. The battle
offer stays at zero — Telegram builds that text out of the article, so the bot
never sees it, and it is one line long.

Two parameters of `schedule_inline` became the domain types they always were:
`InlineMessageId`, which the service used to build out of a `&str` a line
later, and a new `CharCount`, counting characters rather than bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kozalosev
kozalosev merged commit 93c2272 into main Aug 11, 2026
2 checks passed
@kozalosev
kozalosev deleted the feature/self-destruction-configurable-per-chat branch August 11, 2026 22:59
@kozalosev kozalosev added the enhancement New feature or request label Aug 11, 2026
@kozalosev kozalosev added this to the v1.5.0 milestone Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Make self-deletion of messages per-group configurable

1 participant