bb18dc362b
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The topic info card built the profile link as `tg://user?id=`, which clients render as dead text for a user they don't already know (the test-vs-prod difference an operator saw), and it put the raw display name through HTML — so a name starting with "/" was auto-detected as a tappable bot command that fired when tapped. Render the card with message entities instead: the display name is covered by a `text_mention` entity (the reliable way to mention a username-less user the bot has already seen — it messaged the bot), which links the profile AND, because the name sits inside an entity, suppresses the "/command" and "@mention" auto-detection on it. Drop the HTML parse mode and the tg:// link. Entity offsets are UTF-16.
437 lines
14 KiB
Go
437 lines
14 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/go-telegram/bot/models"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/platform/telegram/internal/support"
|
|
)
|
|
|
|
const supportChatID = -1001000000000
|
|
|
|
// copyRec records one copyMessage call.
|
|
type copyRec struct {
|
|
chatID, fromChatID, threadID, messageID string
|
|
}
|
|
|
|
// supportAPI is a fake Bot API for the support-relay handlers: it answers the
|
|
// methods they call with incrementing ids and records the requests for assertions.
|
|
type supportAPI struct {
|
|
mu sync.Mutex
|
|
adminIDs []int64
|
|
nextThread int
|
|
nextMsg int
|
|
topicsOpened int
|
|
copies []copyRec
|
|
headerThread string // message_thread_id of the last header sendMessage
|
|
deleted [][]int
|
|
editedMsgIDs []string
|
|
answers []string
|
|
}
|
|
|
|
func newSupportAPI(adminIDs ...int64) *supportAPI {
|
|
return &supportAPI{adminIDs: adminIDs, nextThread: 1000, nextMsg: 5000}
|
|
}
|
|
|
|
func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
path := r.URL.Path
|
|
switch {
|
|
case strings.HasSuffix(path, "/getMe"):
|
|
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"bot","username":"b"}}`)
|
|
case strings.HasSuffix(path, "/createForumTopic"):
|
|
s.topicsOpened++
|
|
tid := s.nextThread
|
|
s.nextThread++
|
|
fmt.Fprintf(w, `{"ok":true,"result":{"message_thread_id":%d,"name":%q}}`, tid, r.FormValue("name"))
|
|
case strings.HasSuffix(path, "/sendMessage"):
|
|
s.headerThread = r.FormValue("message_thread_id")
|
|
mid := s.nextMsg
|
|
s.nextMsg++
|
|
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
|
|
case strings.HasSuffix(path, "/copyMessage"):
|
|
s.copies = append(s.copies, copyRec{
|
|
chatID: r.FormValue("chat_id"),
|
|
fromChatID: r.FormValue("from_chat_id"),
|
|
threadID: r.FormValue("message_thread_id"),
|
|
messageID: r.FormValue("message_id"),
|
|
})
|
|
mid := s.nextMsg
|
|
s.nextMsg++
|
|
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
|
|
case strings.HasSuffix(path, "/deleteMessages"):
|
|
var ids []int
|
|
_ = json.Unmarshal([]byte(r.FormValue("message_ids")), &ids)
|
|
s.deleted = append(s.deleted, ids)
|
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
|
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
|
|
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
|
|
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
|
case strings.HasSuffix(path, "/answerCallbackQuery"):
|
|
s.answers = append(s.answers, r.FormValue("text"))
|
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
|
case strings.HasSuffix(path, "/getChatAdministrators"):
|
|
var b strings.Builder
|
|
b.WriteString(`{"ok":true,"result":[`)
|
|
for i, id := range s.adminIDs {
|
|
if i > 0 {
|
|
b.WriteString(",")
|
|
}
|
|
fmt.Fprintf(&b, `{"status":"administrator","user":{"id":%d,"is_bot":false}}`, id)
|
|
}
|
|
b.WriteString(`]}`)
|
|
io.WriteString(w, b.String())
|
|
default:
|
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
|
}
|
|
}
|
|
|
|
func (s *supportAPI) copyCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.copies)
|
|
}
|
|
|
|
// newSupportBot builds a support-enabled bot against the fake API and returns it with
|
|
// its backing store.
|
|
func newSupportBot(t *testing.T, api http.Handler) (*Bot, *support.Store) {
|
|
t.Helper()
|
|
srv := httptest.NewServer(api)
|
|
t.Cleanup(srv.Close)
|
|
st := support.New(filepath.Join(t.TempDir(), "support.json"))
|
|
b, err := New(Config{
|
|
Token: "123:ABC",
|
|
APIBaseURL: srv.URL,
|
|
MiniAppURL: "https://example.com/telegram/",
|
|
SupportChatID: supportChatID,
|
|
SupportStore: st,
|
|
}, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new support bot: %v", err)
|
|
}
|
|
return b, st
|
|
}
|
|
|
|
// userMsg builds a private direct message from the given user.
|
|
func userMsg(userID int64, text string) *models.Message {
|
|
return &models.Message{
|
|
ID: int(userID)*10 + 1,
|
|
Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate},
|
|
From: &models.User{ID: userID, FirstName: "Ann", Username: "annlee", LanguageCode: "ru"},
|
|
Text: text,
|
|
}
|
|
}
|
|
|
|
func TestSupportFirstContactOpensTopicAndRelays(t *testing.T) {
|
|
api := newSupportAPI()
|
|
b, st := newSupportBot(t, api)
|
|
|
|
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello"))
|
|
|
|
if api.topicsOpened != 1 {
|
|
t.Fatalf("topics opened = %d, want 1", api.topicsOpened)
|
|
}
|
|
rec, ok := st.Get(7)
|
|
if !ok || rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
|
|
t.Fatalf("store = %+v ok=%v, want topic 1000 / header 5000", rec, ok)
|
|
}
|
|
if api.headerThread != "1000" {
|
|
t.Errorf("header posted to thread %q, want 1000", api.headerThread)
|
|
}
|
|
if len(api.copies) != 1 {
|
|
t.Fatalf("copies = %d, want 1", len(api.copies))
|
|
}
|
|
c := api.copies[0]
|
|
if c.threadID != "1000" || c.fromChatID != "7" || c.chatID != fmt.Sprint(supportChatID) {
|
|
t.Errorf("copy = %+v, want thread 1000 from 7 into the support chat", c)
|
|
}
|
|
if len(rec.RelayedMsgIDs) != 1 {
|
|
t.Errorf("relayed ids = %v, want 1 tracked", rec.RelayedMsgIDs)
|
|
}
|
|
}
|
|
|
|
func TestSupportSubsequentReusesTopic(t *testing.T) {
|
|
api := newSupportAPI()
|
|
b, st := newSupportBot(t, api)
|
|
|
|
b.handleSupportUserMessage(context.Background(), userMsg(7, "first"))
|
|
b.handleSupportUserMessage(context.Background(), userMsg(7, "second"))
|
|
|
|
if api.topicsOpened != 1 {
|
|
t.Errorf("topics opened = %d, want 1 (topic reused)", api.topicsOpened)
|
|
}
|
|
if len(api.copies) != 2 {
|
|
t.Errorf("copies = %d, want 2", len(api.copies))
|
|
}
|
|
rec, _ := st.Get(7)
|
|
if len(rec.RelayedMsgIDs) != 2 {
|
|
t.Errorf("relayed ids = %v, want 2", rec.RelayedMsgIDs)
|
|
}
|
|
}
|
|
|
|
func TestSupportBlockedUserDropped(t *testing.T) {
|
|
api := newSupportAPI()
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
if err := st.SetBlocked(7, true); err != nil {
|
|
t.Fatalf("block: %v", err)
|
|
}
|
|
|
|
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello?"))
|
|
|
|
if api.copyCount() != 0 {
|
|
t.Errorf("a blocked user's message was relayed (%d copies)", api.copyCount())
|
|
}
|
|
if api.topicsOpened != 0 {
|
|
t.Errorf("a blocked user opened a topic (%d)", api.topicsOpened)
|
|
}
|
|
}
|
|
|
|
// supportGroupMsg builds an operator message inside a topic of the support chat.
|
|
func supportGroupMsg(fromID int64, threadID int, isBot bool) *models.Message {
|
|
return &models.Message{
|
|
ID: 700 + threadID,
|
|
Chat: models.Chat{ID: supportChatID, Type: models.ChatTypeSupergroup},
|
|
From: &models.User{ID: fromID, IsBot: isBot},
|
|
MessageThreadID: threadID,
|
|
Text: "operator reply",
|
|
}
|
|
}
|
|
|
|
func TestSupportOperatorReplyRelayed(t *testing.T) {
|
|
api := newSupportAPI(50) // user 50 is an admin
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
|
|
b.handleSupportGroupMessage(context.Background(), supportGroupMsg(50, 1000, false))
|
|
|
|
if len(api.copies) != 1 {
|
|
t.Fatalf("copies = %d, want 1 (reply relayed to user)", len(api.copies))
|
|
}
|
|
if api.copies[0].chatID != "7" || api.copies[0].fromChatID != fmt.Sprint(supportChatID) {
|
|
t.Errorf("copy = %+v, want from the support chat to user 7", api.copies[0])
|
|
}
|
|
rec, _ := st.Get(7)
|
|
if len(rec.RelayedMsgIDs) != 1 {
|
|
t.Errorf("operator message not tracked for clear: %v", rec.RelayedMsgIDs)
|
|
}
|
|
}
|
|
|
|
func TestSupportGroupMessageIgnored(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg *models.Message
|
|
admins []int64
|
|
}{
|
|
{"bot's own copy (loop guard)", supportGroupMsg(1, 1000, true), []int64{50}},
|
|
{"non-admin sender", supportGroupMsg(999, 1000, false), []int64{50}},
|
|
{"outside any topic", supportGroupMsg(50, 0, false), []int64{50}},
|
|
{"unknown topic", supportGroupMsg(50, 4242, false), []int64{50}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := newSupportAPI(tc.admins...)
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
b.handleSupportGroupMessage(context.Background(), tc.msg)
|
|
if api.copyCount() != 0 {
|
|
t.Errorf("message relayed (%d copies); it should be ignored", api.copyCount())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// supportCallback builds a callback-query update for the given data and presser.
|
|
func supportCallback(data string, fromID int64) *models.Update {
|
|
return &models.Update{CallbackQuery: &models.CallbackQuery{
|
|
ID: "cb1",
|
|
From: models.User{ID: fromID},
|
|
Data: data,
|
|
}}
|
|
}
|
|
|
|
func TestSupportCallbackBlockToggle(t *testing.T) {
|
|
api := newSupportAPI(50)
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
|
|
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 50))
|
|
if !st.Blocked(7) {
|
|
t.Error("user not blocked after sup:block")
|
|
}
|
|
if len(api.editedMsgIDs) != 1 || api.editedMsgIDs[0] != "5000" {
|
|
t.Errorf("edited markup msg ids = %v, want [5000]", api.editedMsgIDs)
|
|
}
|
|
|
|
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:unblock:7", 50))
|
|
if st.Blocked(7) {
|
|
t.Error("user still blocked after sup:unblock")
|
|
}
|
|
if len(api.answers) != 2 || api.answers[0] != "Заблокирован" || api.answers[1] != "Разблокирован" {
|
|
t.Errorf("answers = %v, want [Заблокирован Разблокирован]", api.answers)
|
|
}
|
|
}
|
|
|
|
func TestSupportCallbackNonAdminDenied(t *testing.T) {
|
|
api := newSupportAPI(50)
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
|
|
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 999))
|
|
|
|
if st.Blocked(7) {
|
|
t.Error("a non-admin managed to block the user")
|
|
}
|
|
if len(api.answers) != 1 || api.answers[0] != "Недостаточно прав" {
|
|
t.Errorf("answers = %v, want a permission denial", api.answers)
|
|
}
|
|
}
|
|
|
|
func TestSupportCallbackClearDeletesTracked(t *testing.T) {
|
|
api := newSupportAPI(50)
|
|
b, st := newSupportBot(t, api)
|
|
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
|
t.Fatalf("seed topic: %v", err)
|
|
}
|
|
for _, id := range []int{501, 502, 503} {
|
|
if err := st.AppendMsg(7, id); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
}
|
|
|
|
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:clear:7", 50))
|
|
|
|
if len(api.deleted) != 1 || len(api.deleted[0]) != 3 {
|
|
t.Fatalf("deleted batches = %v, want one batch of 3", api.deleted)
|
|
}
|
|
rec, _ := st.Get(7)
|
|
if len(rec.RelayedMsgIDs) != 0 {
|
|
t.Errorf("relayed ids after clear = %v, want empty", rec.RelayedMsgIDs)
|
|
}
|
|
if rec.HeaderMsgID != 5000 {
|
|
t.Errorf("clear disturbed the header (%d)", rec.HeaderMsgID)
|
|
}
|
|
}
|
|
|
|
func TestParseSupportCallback(t *testing.T) {
|
|
cases := []struct {
|
|
data string
|
|
wantAction string
|
|
wantUID int64
|
|
wantOK bool
|
|
}{
|
|
{"sup:block:7", "block", 7, true},
|
|
{"sup:clear:-100", "clear", -100, true},
|
|
{"sup:unblock:42", "unblock", 42, true},
|
|
{"block:7", "", 0, false},
|
|
{"sup:block", "", 0, false},
|
|
{"sup:block:notanumber", "", 0, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.data, func(t *testing.T) {
|
|
action, uid, ok := parseSupportCallback(tc.data)
|
|
if action != tc.wantAction || uid != tc.wantUID || ok != tc.wantOK {
|
|
t.Errorf("parseSupportCallback(%q) = %q,%d,%v; want %q,%d,%v",
|
|
tc.data, action, uid, ok, tc.wantAction, tc.wantUID, tc.wantOK)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSupportCard(t *testing.T) {
|
|
t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
|
|
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
|
|
text, ents := supportCard(u)
|
|
// The name is rendered verbatim (no HTML, no escaping) at the start of the card.
|
|
if !strings.HasPrefix(text, "/start\n") {
|
|
t.Errorf("card = %q, want it to start with the raw name", text)
|
|
}
|
|
// A text_mention entity covers exactly the name with the user id — this both
|
|
// suppresses the "/start" command auto-detection and links the profile.
|
|
if len(ents) != 1 {
|
|
t.Fatalf("entities = %d, want 1", len(ents))
|
|
}
|
|
e := ents[0]
|
|
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
|
|
t.Errorf("entity = %+v, want a text_mention over the name", e)
|
|
}
|
|
if e.User == nil || e.User.ID != 7 {
|
|
t.Errorf("entity user = %+v, want id 7", e.User)
|
|
}
|
|
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
|
|
t.Errorf("card = %q, want no tg:// link and no HTML", text)
|
|
}
|
|
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
|
|
t.Errorf("card = %q, want premium + @username", text)
|
|
}
|
|
})
|
|
|
|
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
|
|
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
|
|
u := &models.User{ID: 9, FirstName: "😀A"}
|
|
_, ents := supportCard(u)
|
|
if len(ents) != 1 || ents[0].Length != 3 {
|
|
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
|
|
}
|
|
})
|
|
|
|
t.Run("nameless user falls back to an id label", func(t *testing.T) {
|
|
u := &models.User{ID: 42}
|
|
text, ents := supportCard(u)
|
|
if !strings.HasPrefix(text, "user 42") {
|
|
t.Errorf("card = %q, want the id fallback name", text)
|
|
}
|
|
if ents[0].Length != len("user 42") {
|
|
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSupportTopicName(t *testing.T) {
|
|
if got := supportTopicName(&models.User{ID: 7, FirstName: "Ann", LastName: "Lee", Username: "annlee"}); got != "Ann Lee @annlee" {
|
|
t.Errorf("topic name = %q, want %q", got, "Ann Lee @annlee")
|
|
}
|
|
if got := supportTopicName(&models.User{ID: 7}); got != "user 7" {
|
|
t.Errorf("nameless topic = %q, want %q", got, "user 7")
|
|
}
|
|
long := strings.Repeat("x", 200)
|
|
if got := supportTopicName(&models.User{ID: 7, FirstName: long}); len([]rune(got)) != supportTopicNameMax {
|
|
t.Errorf("topic name length = %d, want %d (trimmed)", len([]rune(got)), supportTopicNameMax)
|
|
}
|
|
}
|
|
|
|
func TestSupportDisabledFallsThrough(t *testing.T) {
|
|
// With no SupportChatID the relay is off and supportEnabled is false.
|
|
srv := httptest.NewServer(&supportAPI{nextThread: 1000, nextMsg: 5000})
|
|
t.Cleanup(srv.Close)
|
|
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/"}, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new bot: %v", err)
|
|
}
|
|
if b.supportEnabled() {
|
|
t.Error("supportEnabled() = true without a SupportChatID")
|
|
}
|
|
}
|