fix(telegram): grant in-chat members regardless of reported can_send
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s

The CanSendMessages loop-guard skipped exactly the stuck case — a restricted
member whose chat_member event reports can_send=true yet who cannot actually
write. Replace it with a precise loop guard (skip only the bot's own restrict
action, i.e. the update whose performer is the bot) and grant any eligible
in-chat member (member or restricted) otherwise. Also log the new member's
can_send, is_member and the actor id for full visibility.
This commit is contained in:
Ilia Denisov
2026-06-21 16:25:57 +02:00
parent 380f82438c
commit 0ab1719ee9
2 changed files with 60 additions and 29 deletions
+27 -12
View File
@@ -3,6 +3,7 @@ package bot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,7 +14,10 @@ import (
"go.uber.org/zap"
)
const testChatID = 555
const (
testChatID = 555
botSelfID = 111111 // the bot's own id in tests (for the loop guard)
)
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
// scripted getChatMember status, and records restrictChatMember calls.
@@ -59,6 +63,7 @@ func newChatBot(t *testing.T, api *chatAPI) *Bot {
if err != nil {
t.Fatalf("new bot: %v", err)
}
b.botID = botSelfID // normally set at startup; the chat_member updates default actor 0 != this
return b
}
@@ -109,14 +114,22 @@ func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) {
}
func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) {
// A default-deny group reports a present member as restricted with no send right —
// the real-world case. Both a fresh join (left->restricted) and an already-present
// restricted member (restricted->restricted) must be granted when eligible.
for _, oldType := range []models.ChatMemberType{models.ChatMemberTypeLeft, models.ChatMemberTypeRestricted} {
t.Run(string(oldType), func(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, oldType, false))
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)
}
@@ -124,14 +137,16 @@ func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) {
}
}
func TestHandleChatMemberSkipsAlreadyAllowed(t *testing.T) {
// The bot's own grant re-fires a chat_member event (restricted, can_send=true); it
// must not loop into another grant.
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.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true))
upd := restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false)
upd.From = models.User{ID: botSelfID}
b.handleChatMember(context.Background(), upd)
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for an already-allowed member (no loop)", api.restricts)
t.Fatalf("restricts = %+v, want none for the bot's own action (no loop)", api.restricts)
}
}