Files
3x-ui/internal/web/service/tgbot/tgbot_broadcast_test.go
T
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00

1033 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tgbot
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
telegoapi "github.com/mymmrac/telego/telegoapi"
"github.com/mymmrac/telego"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
// newBroadcastMock serves ok:true and records per-method call counts and
// bodies; copyMessages answers with an array of ids, as the real API does.
func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies func(string) []map[string]any) {
t.Helper()
var mu sync.Mutex
counts := map[string]int{}
sent := map[string][]map[string]any{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
payload := map[string]any{}
_ = json.Unmarshal(raw, &payload)
method := strings.TrimPrefix(r.URL.Path, "/bot"+testBotToken+"/")
message := map[string]any{"message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
result := any(message)
if method == "copyMessages" {
result = []any{message, message}
}
if method == "sendPhoto" {
// Screens are photo messages whose caption and keyboard travel as form fields; the mock lifts them into
// the JSON shape the assertions read.
payload["text"] = payload["caption"]
}
mu.Lock()
counts[method]++
sent[method] = append(sent[method], payload)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
}))
t.Cleanup(srv.Close)
return srv.URL,
func(method string) int {
mu.Lock()
defer mu.Unlock()
return counts[method]
},
func(method string) []map[string]any {
mu.Lock()
defer mu.Unlock()
return append([]map[string]any(nil), sent[method]...)
}
}
func setBroadcastAdmins(t *testing.T, ids []int64) {
t.Helper()
tgBotMutex.Lock()
orig := adminIds
adminIds = ids
tgBotMutex.Unlock()
t.Cleanup(func() {
tgBotMutex.Lock()
adminIds = orig
tgBotMutex.Unlock()
})
}
func setBroadcastRunning(t *testing.T, running bool) {
t.Helper()
tgBotMutex.Lock()
orig := isRunning
isRunning = running
tgBotMutex.Unlock()
t.Cleanup(func() {
tgBotMutex.Lock()
isRunning = orig
tgBotMutex.Unlock()
})
}
func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) ([]int, error), pause func(time.Duration)) {
t.Helper()
origSend, origPause := broadcastSender, broadcastPause
t.Cleanup(func() {
broadcastSender, broadcastPause = origSend, origPause
})
broadcastSender = sender
if pause != nil {
broadcastPause = pause
}
}
// broadcastLocalizer renders the broadcast keys a test asserts on; without it
// I18n returns the bare key instead of the template output.
func broadcastLocalizer(t *testing.T) {
t.Helper()
bundle := i18n.NewBundle(language.MustParse("en-US"))
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
_ = bundle.AddMessages(language.MustParse("en-US"),
&i18n.Message{ID: "tgbot.messages.broadcastPreview", Other: "📤 This message will go to {{ .Count }} recipients. Send it?"},
&i18n.Message{ID: "tgbot.messages.broadcastNotCopyable", Other: "❗ This message can't be copied for broadcast."},
&i18n.Message{ID: "tgbot.messages.broadcastAskText", Other: "send the message"},
&i18n.Message{ID: "tgbot.messages.broadcastAlreadyRunning", Other: "already running"},
&i18n.Message{ID: "tgbot.messages.broadcastProgress", Other: "progress {{ .Sent }}/{{ .Total }} failed {{ .Failed }}"},
&i18n.Message{ID: "tgbot.messages.broadcastFinished", Other: "finished"},
&i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"},
&i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."},
)
orig := locale.LocalizerBot
t.Cleanup(func() { locale.LocalizerBot = orig })
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
}
func createBroadcastInbound(t *testing.T, tag, settings string) {
t.Helper()
inbound := &model.Inbound{Tag: tag, Settings: settings, Enable: true}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
}
// broadcastClientsJSON renders an inbound settings blob with the given tgIds.
func broadcastClientsJSON(t *testing.T, tgIDs ...int64) string {
t.Helper()
clients := make([]string, 0, len(tgIDs))
for i, tgID := range tgIDs {
clients = append(clients, fmt.Sprintf(`{"email":"user%d@x","tgId":%d}`, i, tgID))
}
return `{"clients":[` + strings.Join(clients, ",") + `]}`
}
// resetBroadcastState clears the shared broadcast globals before a test
// asserts on them: shuffled tests may inherit state from an earlier test.
func resetBroadcastState(t *testing.T) {
t.Helper()
broadcastResetAll()
userStateMgr.reset()
}
// composeBroadcast hands a message to the composition step the way the router
// does: keyed by its sender, who is awaiting broadcast input.
func composeBroadcast(tb *Tgbot, message telego.Message) {
actor := messageActor(message)
userStateMgr.set(actor, broadcastAwaitingText)
tb.handleBroadcastInput(&message, actor)
}
func swapAlbumDebounce(t *testing.T, d time.Duration) {
t.Helper()
orig := broadcastAlbumDebounce
t.Cleanup(func() { broadcastAlbumDebounce = orig })
broadcastAlbumDebounce = d
}
// waitBroadcastPending polls until the debounce finalizer has stored a draft
// and returns its ids and card token.
func waitBroadcastPending(t *testing.T, actor chatUser) ([]int, string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if ids, token, ok := broadcastPendingDraft(actor); ok {
return ids, token
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("album draft was never finalized in time")
return nil, ""
}
func TestCollectBroadcastRecipients(t *testing.T) {
tb := newStaleButtonTgbot(t)
setBroadcastAdmins(t, []int64{222})
// 111 appears on both inbounds, 222 is an admin, 0 has no Telegram ID.
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 111, 111, 222))
createBroadcastInbound(t, "in-2", broadcastClientsJSON(t, 111, 333, 0, 444))
got := tb.collectBroadcastRecipients()
slices.Sort(got)
if !slices.Equal(got, []int64{111, 333, 444}) {
t.Fatalf("collectBroadcastRecipients() = %v, want [111 333 444]", got)
}
}
func assertBroadcastResult(t *testing.T, got, want broadcastResult) {
t.Helper()
got.Elapsed, want.Elapsed = 0, 0
if got != want {
t.Errorf("result = %+v, want %+v", got, want)
}
}
func TestRunBroadcastCounters(t *testing.T) {
broadcastLocalizer(t)
url, _, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"}
tests := []struct {
name string
recipients []int64
outcomes map[int64]error
delivered int
failed int
skipped int
unreachable int
}{
{"all delivered", []int64{1, 2, 3}, map[int64]error{1: nil, 2: nil, 3: nil}, 3, 0, 0, 0},
{
"a blocked chat is skipped and a transient error fails",
[]int64{1, 2, 3, 4},
map[int64]error{1: nil, 2: blocked, 3: nil, 4: errors.New("connection reset")},
2, 1, 1, 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
if err := tt.outcomes[chatID]; err != nil {
return nil, err
}
return []int{int(chatID)}, nil
}, func(time.Duration) {})
runner := &broadcastRunner{chatID: 100, messageID: 5}
tb := &Tgbot{}
tb.runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, tt.recipients)
assertBroadcastResult(t, runner.getResult(), broadcastResult{
Total: len(tt.recipients),
Delivered: tt.delivered,
Failed: tt.failed,
Skipped: tt.skipped,
Unreachable: tt.unreachable,
})
summary := tb.broadcastSummaryText(runner.getResult())
if tt.unreachable == 0 {
if strings.Contains(summary, "press Start") {
t.Errorf("summary = %q, want no unreachable note", summary)
}
} else if !strings.Contains(summary, "1 recipients cannot be messaged") {
t.Errorf("summary = %q, want the unreachable note with the count", summary)
}
})
}
}
func TestRunBroadcastCancelsMidway(t *testing.T) {
url, _, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
runner := &broadcastRunner{chatID: 100, messageID: 5}
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
if chatID == 1 {
runner.cancel.Store(true)
}
return []int{int(chatID)}, nil
}, func(time.Duration) {})
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
assertBroadcastResult(t, runner.getResult(), broadcastResult{
Total: 5,
Delivered: 1,
Failed: 0,
Skipped: 4,
Canceled: true,
})
if broadcastCurrentRunner() != nil {
t.Errorf("broadcast slot still registered after the run finished")
}
}
// Regression: a 403 left the progress counter where it was, so a streak of
// unreachable chats at a multiple of broadcastProgressEvery edited the card per chat.
func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) {
broadcastLocalizer(t)
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"}
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, blocked }, func(time.Duration) {})
runner := &broadcastRunner{chatID: 100, messageID: 5}
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
// Five recipients sit under both throttle thresholds: only the summary edits the card.
if got := calls("editMessageText"); got != 1 {
t.Errorf("editMessageText calls = %d, want 1 (the summary alone)", got)
}
}
// A long retry_after must not park the runner slot: the wait is slept in
// slices and an abort between them ends the recipient immediately.
func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
flood := &telegoapi.Error{
ErrorCode: 429,
Description: "Too Many Requests: retry after 30",
Parameters: &telegoapi.ResponseParameters{RetryAfter: 30},
}
tests := []struct {
name string
abortAfter int // abort checks answered false before aborting; -1 never aborts
wantPauses int
wantErr string
}{
{"a 30 s wait becomes six 5 s slices", -1, 30, `429 "Too Many Requests: retry after 30", migrate to chat ID: 0, retry after: 30`},
{"an abort between slices ends the wait", 1, 1, errBroadcastAborted.Error()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var mu sync.Mutex
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, flood }, func(d time.Duration) {
mu.Lock()
defer mu.Unlock()
pauses = append(pauses, d)
})
checks := 0
aborted := func() bool {
if tt.abortAfter < 0 {
return false
}
checks++
return checks > tt.abortAfter
}
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
mu.Lock()
defer mu.Unlock()
if err == nil || err.Error() != tt.wantErr {
t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr)
}
if len(pauses) != tt.wantPauses {
t.Fatalf("pauses = %d slices, want %d", len(pauses), tt.wantPauses)
}
for i, p := range pauses {
if p != broadcastFloodWaitSlice {
t.Errorf("pauses[%d] = %v, want %v", i, p, broadcastFloodWaitSlice)
}
}
})
}
}
// Regression: broadcast state used to survive a stop, leaving an armed album
// timer, a confirmable token and a held runner slot behind.
func TestStopBotResetsBroadcastState(t *testing.T) {
broadcastLocalizer(t)
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
swapAlbumDebounce(t, 20*time.Millisecond)
setBroadcastAdmins(t, []int64{5000})
const chatID = int64(9116)
resetBroadcastState(t)
origRunning := isRunning
t.Cleanup(func() {
tgBotMutex.Lock()
isRunning = origRunning
tgBotMutex.Unlock()
})
composeBroadcast(&Tgbot{}, telego.Message{
Chat: telego.Chat{ID: chatID},
From: &telego.User{ID: 5000},
MessageID: 1,
MediaGroupID: "grpR",
})
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
if runner == nil {
t.Fatal("broadcastRegisterRunner() = nil before the stop")
}
StopBot()
if broadcastCurrentRunner() != nil {
t.Errorf("broadcast slot survived StopBot")
}
if !runner.cancel.Load() {
t.Errorf("the active run was not cancelled on stop")
}
if _, _, ok := broadcastPendingDraft(chatUser{chatID: chatID, userID: 5000}); ok {
t.Errorf("a composition survived StopBot")
}
time.Sleep(60 * time.Millisecond)
if got := calls("copyMessage") + calls("sendMessage"); got != 0 {
t.Errorf("an armed album timer fired after StopBot (%d calls)", got)
}
}
// The per-recipient pause scales with the copied batch size so an album does
// not multiply the messages per second on the wire.
func TestRunBroadcastAlbumPacing(t *testing.T) {
url, _, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, nil }, func(d time.Duration) {
pauses = append(pauses, d)
})
runner := &broadcastRunner{chatID: 100, messageID: 5}
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{1, 2, 3}}, []int64{1, 2})
if len(pauses) != 1 || pauses[0] != 3*broadcastSendDelay {
t.Errorf("pauses = %v, want one pause of %v for a three-message album", pauses, 3*broadcastSendDelay)
}
}
func TestBroadcastDeliverOneRetries429(t *testing.T) {
flood := func(after int) error {
return &telegoapi.Error{
ErrorCode: 429,
Description: "Too Many Requests: retry after " + fmt.Sprint(after),
Parameters: &telegoapi.ResponseParameters{RetryAfter: after},
}
}
tests := []struct {
name string
responses []error
canceled bool
wantErr string
wantCalls int
wantPauses []time.Duration
}{
{
name: "flood control waits and retries the same recipient",
responses: []error{flood(2), nil},
wantCalls: 2,
wantPauses: []time.Duration{2 * time.Second},
},
{
name: "gives up after the retry budget",
responses: []error{flood(1), flood(1), flood(1), flood(1), flood(1), flood(1), nil},
wantErr: `429 "Too Many Requests: retry after 1", migrate to chat ID: 0, retry after: 1`,
wantCalls: 6,
wantPauses: []time.Duration{time.Second, time.Second, time.Second, time.Second, time.Second},
},
{
name: "non-429 errors are returned without retrying",
responses: []error{errors.New("connection reset")},
wantErr: "connection reset",
wantCalls: 1,
},
{
name: "a cancel during a flood wait abandons the recipient",
responses: []error{flood(2), nil},
canceled: true,
wantErr: errBroadcastAborted.Error(),
wantCalls: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var mu sync.Mutex
callNum := 0
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
mu.Lock()
defer mu.Unlock()
callNum++
if callNum > len(tt.responses) {
return []int{callNum}, nil
}
if err := tt.responses[callNum-1]; err != nil {
return nil, err
}
return []int{callNum}, nil
}, func(d time.Duration) {
mu.Lock()
defer mu.Unlock()
pauses = append(pauses, d)
})
canceled := func() bool { return tt.canceled }
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
mu.Lock()
defer mu.Unlock()
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr)
}
} else if err != nil {
t.Errorf("broadcastDeliverOne() error = %v, want nil", err)
}
if callNum != tt.wantCalls {
t.Errorf("sender calls = %d, want %d", callNum, tt.wantCalls)
}
if len(pauses) != len(tt.wantPauses) {
t.Fatalf("pauses = %v, want %v", pauses, tt.wantPauses)
}
for i, p := range tt.wantPauses {
if pauses[i] != p {
t.Errorf("pauses[%d] = %v, want %v", i, pauses[i], p)
}
}
})
}
}
func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) {
first := broadcastRegisterRunner(chatUser{chatID: 1})
if first == nil {
t.Fatal("broadcastRegisterRunner() = nil for an idle bot")
}
t.Cleanup(func() { broadcastUnregisterRunner(first) })
if second := broadcastRegisterRunner(chatUser{chatID: 2}); second != nil {
t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second)
}
broadcastUnregisterRunner(first)
if broadcastCurrentRunner() != nil {
t.Fatalf("slot still registered after unregister")
}
}
func TestStartBroadcastRefusesWhileRunning(t *testing.T) {
broadcastLocalizer(t)
const chatID = int64(9102)
resetBroadcastState(t)
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
t.Cleanup(func() {
broadcastUnregisterRunner(runner)
})
admin := chatUser{chatID: chatID, userID: chatID}
(&Tgbot{}).startBroadcast(admin)
if state, ok := userStateMgr.get(admin); ok {
t.Fatalf("state = %q while a broadcast is running, want none", state)
}
}
func TestBroadcastCommandRequiresAdmin(t *testing.T) {
const chatID = int64(9101)
resetBroadcastState(t)
message := &telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"}
(&Tgbot{}).answerCommand(message, chatID, false)
if state, ok := userStateMgr.get(messageActor(*message)); ok {
t.Fatalf("non-admin /broadcast set state %q", state)
}
if _, _, ok := broadcastPendingDraft(messageActor(*message)); ok {
t.Fatalf("non-admin /broadcast produced a draft")
}
if broadcastCurrentRunner() != nil {
t.Fatalf("non-admin /broadcast started a runner")
}
}
func TestBroadcastStartCommandSetsState(t *testing.T) {
broadcastLocalizer(t)
const chatID = int64(9103)
resetBroadcastState(t)
defer func() {
userStateMgr.reset()
broadcastResetAll()
}()
(&Tgbot{}).answerCommand(&telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"}, chatID, true)
state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: chatID})
if !ok || state != broadcastAwaitingText {
t.Fatalf("state = %q (ok=%v), want %q", state, ok, broadcastAwaitingText)
}
}
func TestDeliverBroadcastCopy(t *testing.T) {
tests := []struct {
name string
draft broadcastDraft
wantMethods map[string]int
check func(t *testing.T, bodies func(string) []map[string]any)
}{
{
name: "a single message rides copyMessage",
draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{7}},
wantMethods: map[string]int{"copyMessage": 1},
check: func(t *testing.T, bodies func(string) []map[string]any) {
body := bodies("copyMessage")[0]
if fmt.Sprint(body["from_chat_id"]) != "55" || fmt.Sprint(body["message_id"]) != "7" {
t.Errorf("copy body = %v, want from 55 message 7", body)
}
},
},
{
name: "an album rides one copyMessages call",
draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{1, 2, 3}},
wantMethods: map[string]int{"copyMessages": 1, "copyMessage": 0},
check: func(t *testing.T, bodies func(string) []map[string]any) {
if fmt.Sprint(bodies("copyMessages")[0]["message_ids"]) != "[1 2 3]" {
t.Errorf("message_ids = %v, want [1 2 3]", bodies("copyMessages")[0]["message_ids"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// A fresh mock per case keeps the per-method counts independent.
url, calls, bodies := newBroadcastMock(t)
swapTestBot(t, url)
if _, err := deliverBroadcastCopy(66, tt.draft); err != nil {
t.Fatalf("deliverBroadcastCopy() error = %v", err)
}
for method, want := range tt.wantMethods {
if got := calls(method); got != want {
t.Errorf("%s calls = %d, want %d", method, got, want)
}
}
if tt.check != nil {
tt.check(t, bodies)
}
})
}
}
// Regression: a media group used to produce one draft per photo, so three
// photos meant three previews and only the last tapped one was delivered.
func TestHandleBroadcastInputMediaGroup(t *testing.T) {
broadcastLocalizer(t)
url, calls, bodies := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
swapAlbumDebounce(t, 20*time.Millisecond)
tb := newStaleButtonTgbot(t)
setBroadcastAdmins(t, []int64{5000})
const chatID = int64(9109)
resetBroadcastState(t)
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 601))
// Updates of one album arrive out of order and copyMessages demands
// strictly increasing ids, so the draft must sort them.
for _, id := range []int{103, 101, 102} {
composeBroadcast(tb, telego.Message{
Chat: telego.Chat{ID: chatID},
From: &telego.User{ID: 5000},
MessageID: id,
MediaGroupID: "grp9",
Photo: []telego.PhotoSize{{FileID: "unused"}},
})
}
ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
if !slices.Equal(ids, []int{101, 102, 103}) {
t.Fatalf("album draft ids = %v, want [101 102 103]", ids)
}
if token == "" {
t.Fatalf("album draft has no confirmation token")
}
if state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 5000}); ok {
t.Errorf("state = %q after the album was accepted, want cleared", state)
}
if got := calls("copyMessages"); got != 1 {
t.Errorf("copyMessages calls = %d, want 1 self-copy of the whole album", got)
}
// copyMessages rejects ids that are not strictly increasing.
if got := fmt.Sprint(bodies("copyMessages")[0]["message_ids"]); got != "[101 102 103]" {
t.Errorf("self-copy message_ids = %v, want [101 102 103]", got)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && calls("sendMessage") == 0 {
time.Sleep(2 * time.Millisecond)
}
if calls("sendMessage") != 1 {
t.Errorf("sendMessage calls = %d, want 1 confirmation card", calls("sendMessage"))
}
}
func TestHandleBroadcastInputSingleMessage(t *testing.T) {
broadcastLocalizer(t)
url, calls, bodies := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
tb := newStaleButtonTgbot(t)
setBroadcastAdmins(t, []int64{5000})
const chatID = int64(9104)
resetBroadcastState(t)
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 602))
composeBroadcast(tb, telego.Message{
Chat: telego.Chat{ID: chatID},
From: &telego.User{ID: 5000},
MessageID: 42,
Text: "hello all",
})
ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
if !slices.Equal(ids, []int{42}) {
t.Fatalf("draft ids = %v, want a reference to message 42", ids)
}
if got := calls("copyMessage"); got != 1 {
t.Errorf("copyMessage calls = %d, want 1 self-copy preview", got)
}
card := bodies("sendMessage")[0]
confirmData := card["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"]
if confirmData != "broadcast_confirm "+token {
t.Errorf("card button = %v, want a confirm bound to the pending token %q", confirmData, token)
}
}
// Regression: tapping Send on a superseded preview card delivered whatever
// draft happened to be pending instead of the card's own composition.
func TestBroadcastConfirmStaleTokenRejected(t *testing.T) {
broadcastLocalizer(t)
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
tb := newStaleButtonTgbot(t)
setBroadcastAdmins(t, []int64{5000})
const chatID = int64(9112)
resetBroadcastState(t)
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 603))
composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 11, Text: "first"})
_, staleToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
// A second composition replaces the first, so the first card goes stale.
composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 12, Text: "second"})
ids, liveToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
if !slices.Equal(ids, []int{12}) {
t.Fatalf("draft ids = %v, want only the second message", ids)
}
previewCopies := calls("copyMessage")
tb.answerCallback(&telego.CallbackQuery{
ID: "stale",
From: telego.User{ID: 5000},
Data: "broadcast_confirm " + staleToken,
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
}, true)
if calls("copyMessage") != previewCopies {
t.Fatalf("a stale token started deliveries")
}
if broadcastCurrentRunner() != nil {
t.Fatalf("a stale token started a runner")
}
tb.answerCallback(&telego.CallbackQuery{
ID: "live",
From: telego.User{ID: 5000},
Data: "broadcast_confirm " + liveToken,
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 6},
}, true)
waitBroadcastFinished(t)
if calls("copyMessage") != previewCopies+1 {
t.Errorf("copyMessage calls = %d, want %d (the live draft delivered once)", calls("copyMessage"), previewCopies+1)
}
}
// Regression: composition stayed keyed by chat after the state moved to the
// admin, so a second admin's draft in a group dropped the first admin's.
func TestBroadcastComposesArePerAdmin(t *testing.T) {
broadcastLocalizer(t)
url, _, bodies := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
tb := newStaleButtonTgbot(t)
const groupChat, adminA, adminB = int64(-1009113), int64(5000), int64(5001)
setBroadcastAdmins(t, []int64{adminA, adminB})
resetBroadcastState(t)
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 604))
composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminA}, MessageID: 21, Text: "from A"})
composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminB}, MessageID: 22, Text: "from B"})
cards := bodies("sendMessage")
if len(cards) != 2 {
t.Fatalf("sendMessage calls = %d, want one preview card per admin", len(cards))
}
// Each admin confirms their own card, A first; each run must deliver its own draft.
for i, admin := range []int64{adminA, adminB} {
confirm := cards[i]["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"].(string)
tb.answerCallback(&telego.CallbackQuery{
ID: "q",
From: telego.User{ID: admin},
Data: confirm,
Message: &telego.Message{Chat: telego.Chat{ID: groupChat}, MessageID: 30 + i},
}, true)
waitBroadcastFinished(t)
}
var delivered []string
for _, body := range bodies("copyMessage") {
if fmt.Sprint(body["chat_id"]) == "604" {
delivered = append(delivered, fmt.Sprint(body["message_id"]))
}
}
if !slices.Equal(delivered, []string{"21", "22"}) {
t.Errorf("messages delivered to the client = %v, want [21 22]: each admin's own draft", delivered)
}
}
func waitBroadcastFinished(t *testing.T) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if broadcastCurrentRunner() == nil {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("broadcast did not finish in time")
}
func TestConfirmBroadcastEndToEnd(t *testing.T) {
broadcastLocalizer(t)
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
setBroadcastRunning(t, true)
tb := newStaleButtonTgbot(t)
setBroadcastAdmins(t, []int64{5000})
const chatID = int64(9105)
resetBroadcastState(t)
createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 501, 502))
composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 9, Text: "hi"})
_, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: 5000},
Data: "broadcast_confirm " + token,
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
}, true)
waitBroadcastFinished(t)
// One preview self-copy plus two deliveries; the card is edited into the
// progress card and then into the summary, so nothing is sent twice.
if got := calls("copyMessage"); got != 3 {
t.Errorf("copyMessage calls = %d, want 3 (preview + 2 deliveries)", got)
}
if got := calls("sendMessage"); got != 1 {
t.Errorf("sendMessage calls = %d, want 1 confirmation card", got)
}
if got := calls("editMessageText"); got != 2 {
t.Errorf("editMessageText calls = %d, want 2 (progress + summary)", got)
}
if got := calls("answerCallbackQuery"); got != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1", got)
}
}
func TestConfirmBroadcastWithoutDraftAnswersError(t *testing.T) {
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
tb := newStaleButtonTgbot(t)
const chatID = int64(9106)
resetBroadcastState(t)
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: chatID},
Data: "broadcast_confirm sometoken",
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
}, true)
if calls("answerCallbackQuery") != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1 error answer", calls("answerCallbackQuery"))
}
if broadcastCurrentRunner() != nil {
t.Errorf("a confirm without a draft must not start a broadcast")
}
}
func TestBroadcastCancelCallbackClearsDraft(t *testing.T) {
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
tb := newStaleButtonTgbot(t)
const chatID = int64(9107)
resetBroadcastState(t)
admin := chatUser{chatID: chatID, userID: chatID}
userStateMgr.set(admin, broadcastAwaitingText)
broadcastComposes[admin] = &broadcastCompose{messageIDs: []int{9}, token: "tok9"}
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: chatID},
Data: "broadcast_cancel",
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 9},
}, true)
if _, ok := userStateMgr.get(admin); ok {
t.Errorf("state survived the cancel tap")
}
if _, _, ok := broadcastPendingDraft(admin); ok {
t.Errorf("draft survived the cancel tap")
}
if got := calls("deleteMessage"); got != 1 {
t.Errorf("deleteMessage calls = %d, want 1", got)
}
if got := calls("answerCallbackQuery"); got != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1", got)
}
}
func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) {
url, calls, _ := newBroadcastMock(t)
swapTestBot(t, url)
tb := newStaleButtonTgbot(t)
const chatID = int64(9108)
resetBroadcastState(t)
for _, data := range []string{"broadcast_confirm sometoken", "broadcast_cancel"} {
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: 999999},
Data: data,
Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
}, false)
if calls("answerCallbackQuery") != 0 {
t.Fatalf("%s answered a non-admin callback", data)
}
if broadcastCurrentRunner() != nil {
t.Fatalf("%s started a broadcast for a non-admin", data)
}
}
}
// broadcastComposeToken reads the token the pending draft was stored under.
func broadcastComposeToken(t *testing.T, actor chatUser) string {
t.Helper()
_, token, ok := broadcastPendingDraft(actor)
if !ok {
t.Fatal("no pending draft to confirm")
}
return token
}
// The admin's composing message is the broadcast's source, not the broadcast: keeping it, its preview
// and the card put three copies of the text on screen.
func TestBroadcastCleansUpItsComposingMessages(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
broadcastLocalizer(t)
setBroadcastRunning(t, true)
t.Cleanup(func() { resetBroadcastState(t) })
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
// The id Telegram returns for the self-copy preview.
return []int{321}, nil
}, func(time.Duration) {})
tb := initReportDB(t)
const chatID = int64(9100)
admin := chatUser{chatID: chatID, userID: 1}
tb.handleBroadcastInput(&telego.Message{
MessageID: 777,
Chat: telego.Chat{ID: chatID},
From: &telego.User{ID: 1},
Text: "hello everyone",
}, admin)
// The preview copy is the broadcast content: it must leave the chat at once.
if got := countMethod(calls(), "deleteMessage"); got == 0 {
t.Fatal("the self-copy preview was left in the chat")
}
// Confirming must also remove the admin's own input message.
tb.confirmBroadcast(admin, broadcastComposeToken(t, admin), 55, "q1")
if got := countMethod(calls(), "deleteMessage"); got < 2 {
t.Errorf("deleteMessage calls = %d, want the input and the preview removed", got)
}
}
// A cancelled broadcast must not leave its copies in the recipients' chats: the
// summary would say it stopped while every chat that got one still shows it.
func TestCancelledBroadcastRecallsDeliveredCopies(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
broadcastLocalizer(t)
setBroadcastRunning(t, true)
t.Cleanup(func() { resetBroadcastState(t) })
initReportDB(t)
runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5}
delivered := 0
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
delivered++
// The admin cancels mid-run: what already went out has to be withdrawn.
if delivered == 2 {
runner.cancel.Store(true)
}
return []int{900 + delivered}, nil
}, func(time.Duration) {})
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3})
if delivered == 0 {
t.Fatal("nothing was delivered, so there is nothing to recall")
}
if got := countMethod(calls(), "deleteMessage"); got < delivered {
t.Errorf("deleteMessage calls = %d, want one per delivered copy (%d)", got, delivered)
}
}