fix(telegram): invert the chat gate — mute the ineligible (default-allow)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

Telegram intersects the chat default with each user's permissions, so a per-user
grant can never exceed a deny-by-default group: the original default-deny +
grant design could not let any user write (can_send=true was AND-ed with the
denying default). Invert it — the chat allows sending by default and the bot
MUTES an ineligible member (unregistered, admin-suspended, or chat_muted) and
restores an eligible one it had muted, acting only when the current state
differs (idempotent, no self-loop). The block/unblock/chat_muted/registration
path already sets can_send to the eligibility, so it is unchanged.
This commit is contained in:
Ilia Denisov
2026-06-21 16:50:44 +02:00
parent 0ab1719ee9
commit bdd1cc7d85
2 changed files with 100 additions and 66 deletions
+32 -16
View File
@@ -254,10 +254,12 @@ func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
t.eligibility = resolve
}
// handleChatMember grants write access to a user who joins the moderated chat when
// they are registered and not blocked. A non-eligible joiner is left muted (the chat
// defaults to no-send), and a resolve failure fails closed (also left muted). Other
// status changes (leaves, restrictions, admin edits) are ignored.
// handleChatMember keeps a chat member's write access in sync with their eligibility.
// The chat allows sending by default, so the bot mutes an ineligible member (not
// registered, or admin-suspended, or chat_muted) and restores an eligible one it had
// muted; an eligible member that can already send is left untouched. It acts only when
// the current state differs from the desired one, so it is idempotent and does not
// re-act on its own change; a resolve failure makes no change.
func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) {
user := chatMemberUser(cm.NewChatMember)
var uid int64
@@ -289,13 +291,22 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated
if t.botID != 0 && cm.From.ID == t.botID {
return
}
// Act only on a user who is in the chat (member or restricted) — a join into the
// default-deny chat appears as `restricted`, not `member`. Left/kicked and admins
// have nothing to grant here. An eligible user is (re)granted write; revoking a
// now-ineligible user is the chat-gate path's job (an admin block or chat_muted).
// The chat allows sending by default and the bot only restricts: Telegram intersects
// the chat default with the per-user permission, so a per-user grant cannot exceed a
// deny-by-default — the gate must mute the ineligible, not grant the eligible.
// Determine whether the user is in the chat and can currently send: a plain member
// follows the permissive default; a restricted member can send only with
// CanSendMessages, and only while a member.
var inChat, currentlyCanSend bool
switch cm.NewChatMember.Type {
case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted:
case models.ChatMemberTypeMember:
inChat, currentlyCanSend = true, true
case models.ChatMemberTypeRestricted:
inChat, currentlyCanSend = isMember, canSend
default:
return // left / kicked / administrator / owner — not a member to gate
}
if !inChat {
return
}
if t.eligibility == nil {
@@ -307,15 +318,20 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated
t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
return
}
t.log.Info("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible))
if !eligible {
return // not registered or blocked: leave muted
}
if err := t.setChatWrite(ctx, user.ID, true); err != nil {
t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err))
t.log.Info("chat access evaluated",
zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend))
// Desired: an eligible user may send, an ineligible one may not. Act only when the
// current state differs — idempotent, a no-op for the common eligible member, and it
// keeps the bot from re-acting on its own change.
if eligible == currentlyCanSend {
return
}
t.log.Info("chat write granted", zap.Int64("user_id", user.ID))
if err := t.setChatWrite(ctx, user.ID, eligible); err != nil {
t.log.Warn("set chat write failed",
zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err))
return
}
t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible))
}
// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted