Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
2 changed files with 100 additions and 66 deletions
Showing only changes of commit bdd1cc7d85 - Show all commits
+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
+68 -50
View File
@@ -3,7 +3,6 @@ package bot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -77,13 +76,13 @@ func memberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.C
}
// restrictedUpdate builds an oldType -> restricted transition for userID, the new
// member's text-send permission set to canSend. A default-deny group reports a present
// member as restricted with canSend=false.
func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend bool) *models.ChatMemberUpdated {
// member's text-send permission set to canSend and membership to isMember. A muted
// member is restricted with canSend=false; an un-muted one with canSend=true.
func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend, isMember bool) *models.ChatMemberUpdated {
return &models.ChatMemberUpdated{
Chat: models.Chat{ID: chatID},
OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend, IsMember: isMember}},
}
}
@@ -104,45 +103,73 @@ func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot {
return b
}
func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) {
func TestHandleChatMemberMutesIneligibleMember(t *testing.T) {
// An unregistered/blocked member can send by the permissive default, so the bot mutes.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts)
if len(api.restricts) != 1 || api.restricts[0].userID != "777" || api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one mute (can_send=false) for 777", api.restricts)
}
}
func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) {
// A default-deny group reports a present member as restricted — the real-world case.
// A fresh join (left->restricted) or an already-present restricted member
// (restricted->restricted), and regardless of the reported can_send (which can be
// misleading), an eligible member is granted.
for _, tc := range []struct {
old models.ChatMemberType
canSend bool
}{
{models.ChatMemberTypeLeft, false},
{models.ChatMemberTypeRestricted, false},
{models.ChatMemberTypeRestricted, true},
} {
t.Run(fmt.Sprintf("%s_cansend=%v", tc.old, tc.canSend), func(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, tc.old, tc.canSend))
if len(api.restricts) != 1 || !api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one grant for a restricted member", api.restricts)
}
})
func TestHandleChatMemberLeavesEligibleMemberAlone(t *testing.T) {
// An eligible plain member already sends (the permissive default); no action needed.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for an eligible member (already allowed)", api.restricts)
}
}
func TestHandleChatMemberUnmutesEligibleRestricted(t *testing.T) {
// An eligible member the bot had muted (restricted, can_send=false) is restored.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false, true))
if len(api.restricts) != 1 || !api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one un-mute (can_send=true)", api.restricts)
}
}
func TestHandleChatMemberLeavesEligibleAllowedRestrictedAlone(t *testing.T) {
// An eligible restricted member who can already send needs no change — the real case
// from the contour (restricted, can_send=true, eligible).
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for an eligible already-allowed member", api.restricts)
}
}
func TestHandleChatMemberMutesIneligibleRestricted(t *testing.T) {
// An ineligible member who can still send is muted.
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true))
if len(api.restricts) != 1 || api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one mute (can_send=false)", api.restricts)
}
}
func TestHandleChatMemberSkipsNonMember(t *testing.T) {
// A restricted record for a user no longer in the chat (is_member=false) is not acted on.
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, false))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for a non-member", api.restricts)
}
}
func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) {
// The bot's own grant re-fires a chat_member update performed by the bot; it must be
// skipped so a grant never loops into another grant.
// The bot's own restrict re-fires a chat_member update performed by the bot; skip it
// so an action never loops (the resolver here would otherwise mute).
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
upd := restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false)
b := eligibleBot(t, api, false, nil)
upd := memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)
upd.From = models.User{ID: botSelfID}
b.handleChatMember(context.Background(), upd)
if len(api.restricts) != 0 {
@@ -150,30 +177,21 @@ func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) {
}
}
func TestHandleChatMemberSkipsIneligible(t *testing.T) {
func TestHandleChatMemberNoChangeOnResolveError(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false))
b := eligibleBot(t, api, false, context.DeadlineExceeded)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 0 {
t.Fatalf("an ineligible member was granted: %+v (want left muted)", api.restricts)
}
}
func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, true, context.DeadlineExceeded)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false))
if len(api.restricts) != 0 {
t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts)
t.Fatalf("a resolve error still changed access: %+v (want no change)", api.restricts)
}
}
func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b := eligibleBot(t, api, false, nil) // ineligible — would mute if it acted
ctx := context.Background()
b.handleChatMember(ctx, restrictedUpdate(999, 777, models.ChatMemberTypeLeft, false)) // foreign chat
b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave
b.handleChatMember(ctx, memberUpdate(999, 777, models.ChatMemberTypeLeft)) // foreign chat
b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for a foreign chat / a leave", api.restricts)
}