bdd1cc7d85
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.
240 lines
9.4 KiB
Go
240 lines
9.4 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 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, IsMember: isMember}},
|
|
}
|
|
}
|
|
|
|
// 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 TestHandleChatMemberMutesIneligibleMember(t *testing.T) {
|
|
// An unregistered/blocked member can send by the permissive default, so the bot mutes.
|
|
api := &chatAPI{}
|
|
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 mute (can_send=false) for 777", 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 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, false, nil)
|
|
upd := memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)
|
|
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 TestHandleChatMemberNoChangeOnResolveError(t *testing.T) {
|
|
api := &chatAPI{}
|
|
b := eligibleBot(t, api, false, context.DeadlineExceeded)
|
|
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
|
|
if len(api.restricts) != 0 {
|
|
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, false, nil) // ineligible — would mute if it acted
|
|
ctx := context.Background()
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|