Files
scrabble-game/platform/telegram/internal/bot/chat_test.go
T
Ilia Denisov 0ab1719ee9
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
fix(telegram): grant in-chat members regardless of reported can_send
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.
2026-06-21 16:25:57 +02:00

222 lines
8.3 KiB
Go

package bot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
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.
type chatAPI struct {
memberStatus string // the status getChatMember reports (default "left")
restricts []restrictCall
}
type restrictCall struct {
userID string
canSend bool // can_send_messages in the applied permissions
}
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getMe"):
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`)
case strings.HasSuffix(r.URL.Path, "/getChatMember"):
status := a.memberStatus
if status == "" {
status = "left"
}
io.WriteString(w, `{"ok":true,"result":{"status":"`+status+`","user":{"id":`+r.FormValue("user_id")+`,"is_bot":false,"first_name":"u"}}}`)
case strings.HasSuffix(r.URL.Path, "/restrictChatMember"):
var perms struct {
CanSendMessages bool `json:"can_send_messages"`
}
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
io.WriteString(w, `{"ok":true,"result":true}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
}
// newChatBot builds a gating bot (ChatID set) over the fake API, without an
// eligibility resolver — each test wires the one it needs.
func newChatBot(t *testing.T, api *chatAPI) *Bot {
t.Helper()
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/", ChatID: testChatID}, zap.NewNop())
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
}
// memberUpdate builds an oldType -> member transition for userID in chatID.
func memberUpdate(chatID, userID int64, oldType models.ChatMemberType) *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.ChatMemberTypeMember, Member: &models.ChatMemberMember{User: &models.User{ID: userID}}},
}
}
// 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 {
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}},
}
}
// leftUpdate builds a transition to left (a leave) for userID.
func leftUpdate(chatID, userID int64) *models.ChatMemberUpdated {
return &models.ChatMemberUpdated{
Chat: models.Chat{ID: chatID},
OldChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeLeft, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
}
}
// eligibleBot builds a gating bot whose resolver returns (eligible, err).
func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot {
t.Helper()
b := newChatBot(t, api)
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return eligible, err })
return b
}
func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, true, 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)
}
}
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 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)
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 the bot's own action (no loop)", api.restricts)
}
}
func TestHandleChatMemberSkipsIneligible(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false))
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)
}
}
func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
ctx := context.Background()
b.handleChatMember(ctx, restrictedUpdate(999, 777, models.ChatMemberTypeLeft, false)) // 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)
}
}
func TestApplyChatGatePresentMember(t *testing.T) {
for _, tc := range []struct {
name string
allow bool
}{{"grant", true}, {"mute", false}} {
t.Run(tc.name, func(t *testing.T) {
api := &chatAPI{memberStatus: "member"}
b := newChatBot(t, api)
applied, err := b.ApplyChatGate(context.Background(), 777, tc.allow)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !applied {
t.Fatal("applied = false, want true for a present member")
}
if len(api.restricts) != 1 || api.restricts[0].canSend != tc.allow {
t.Fatalf("restricts = %+v, want one with can_send=%v", api.restricts, tc.allow)
}
})
}
}
func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
for _, status := range []string{"left", "kicked", "administrator", "creator"} {
t.Run(status, func(t *testing.T) {
api := &chatAPI{memberStatus: status}
b := newChatBot(t, api)
applied, err := b.ApplyChatGate(context.Background(), 777, false)
if err != nil {
t.Fatalf("apply: %v", err)
}
if applied {
t.Errorf("applied = true for status %q, want a no-op", status)
}
if len(api.restricts) != 0 {
t.Errorf("restricts = %+v for status %q, want none", api.restricts, status)
}
})
}
}