The "Manage Bot" button on a bot's profile deep-links to @BotFather with start=<bot username>. parseBotCommand dropped the argument, so /start <bot> just replied with the generic greeting instead of the per-bot menu. Route "/start <arg>" to the bot's "What do you want to do?" screen (same as /mybots then tapping the bot) when <arg> names one of the sender's own bots by username or id; empty or unknown args keep the greeting.
475 lines
17 KiB
Go
475 lines
17 KiB
Go
package bots
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/memory"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func botFatherUserReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||
t.Helper()
|
||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list user history: %v", err)
|
||
}
|
||
var latest domain.Message
|
||
for _, msg := range list.Messages {
|
||
if msg.From.ID == domain.BotFatherUserID && msg.ID >= latest.ID {
|
||
latest = msg
|
||
}
|
||
}
|
||
if latest.ID == 0 {
|
||
t.Fatal("no @BotFather reply in the user's box")
|
||
}
|
||
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
|
||
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
|
||
}
|
||
return latest
|
||
}
|
||
|
||
// botFatherBotSideMessageID is the id of the bot's own copy of its latest reply
|
||
// to userID -- what the RPC edge resolves query.MessageID to before calling the
|
||
// responder, and what editServiceBotMessage addresses.
|
||
func botFatherBotSideMessageID(t *testing.T, messages *memory.MessageStore, userID int64) int {
|
||
t.Helper()
|
||
list, err := messages.ListByUser(context.Background(), domain.BotFatherUserID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list bot history: %v", err)
|
||
}
|
||
latest := 0
|
||
for _, msg := range list.Messages {
|
||
if msg.From.ID == domain.BotFatherUserID && msg.ID > latest {
|
||
latest = msg.ID
|
||
}
|
||
}
|
||
if latest == 0 {
|
||
t.Fatal("no @BotFather copy of its own reply")
|
||
}
|
||
return latest
|
||
}
|
||
|
||
func mybotsButtonData(msg domain.Message, label string) ([]byte, bool) {
|
||
if msg.ReplyMarkup == nil {
|
||
return nil, false
|
||
}
|
||
for _, row := range msg.ReplyMarkup.Inline {
|
||
for _, button := range row {
|
||
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
|
||
return append([]byte(nil), button.Data...), true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func mybotsHasButton(msg domain.Message, label string) bool {
|
||
_, ok := mybotsButtonData(msg, label)
|
||
return ok
|
||
}
|
||
|
||
func hasMentionEntity(msg domain.Message, mention string) bool {
|
||
for _, e := range msg.Entities {
|
||
if e.Type == domain.MessageEntityMention && e.Length == len(mention) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// pressBotFather clicks the button whose text contains label on the user's latest
|
||
// @BotFather reply and returns the callback answer plus the user's new latest
|
||
// reply (which, for an in-place edit, is the same message updated).
|
||
func pressBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, label string) (domain.BotCallbackAnswer, domain.Message) {
|
||
t.Helper()
|
||
reply := botFatherUserReply(t, messages, userID)
|
||
data, ok := mybotsButtonData(reply, label)
|
||
if !ok {
|
||
t.Fatalf("button %q not in keyboard: %+v", label, reply.ReplyMarkup)
|
||
}
|
||
return pressBotFatherData(t, svc, messages, userID, data)
|
||
}
|
||
|
||
func pressBotFatherData(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, data []byte) (domain.BotCallbackAnswer, domain.Message) {
|
||
t.Helper()
|
||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||
ID: 1,
|
||
BotUserID: domain.BotFatherUserID,
|
||
UserID: userID,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||
MessageID: botFatherBotSideMessageID(t, messages, userID),
|
||
Data: data,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("callback query: %v", err)
|
||
}
|
||
if !handled {
|
||
t.Fatal("callback reported unhandled for @BotFather")
|
||
}
|
||
return answer, botFatherUserReply(t, messages, userID)
|
||
}
|
||
|
||
func makeBotsP(t *testing.T, svc *Service, ownerID int64, prefix string, n int) {
|
||
t.Helper()
|
||
for i := 0; i < n; i++ {
|
||
if _, _, err := svc.CreateBot(context.Background(), ownerID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("%s%d_bot", prefix, i)); err != nil {
|
||
t.Fatalf("create bot %d: %v", i, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeBots(t *testing.T, svc *Service, ownerID int64, n int) {
|
||
t.Helper()
|
||
makeBotsP(t, svc, ownerID, "mb", n)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestMyBotsPickerPaginates(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2001")
|
||
makeBots(t, svc, owner.ID, mybotsPageSize+3)
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
page1 := botFatherUserReply(t, messages, owner.ID)
|
||
if !strings.Contains(page1.Body, "Page 1 of 2") {
|
||
t.Fatalf("page 1 body = %q", page1.Body)
|
||
}
|
||
if mybotsHasButton(page1, "‹ Prev") {
|
||
t.Fatal("first page must not offer Prev")
|
||
}
|
||
if !mybotsHasButton(page1, "Next ›") {
|
||
t.Fatal("first page must offer Next")
|
||
}
|
||
if !mybotsHasButton(page1, "@mb0_bot") {
|
||
t.Fatalf("page 1 missing @mb0_bot: %+v", page1.ReplyMarkup)
|
||
}
|
||
|
||
_, page2 := pressBotFather(t, svc, messages, owner.ID, "Next ›")
|
||
if !strings.Contains(page2.Body, "Page 2 of 2") {
|
||
t.Fatalf("page 2 body = %q", page2.Body)
|
||
}
|
||
if mybotsHasButton(page2, "Next ›") {
|
||
t.Fatal("last page must not offer Next")
|
||
}
|
||
if !mybotsHasButton(page2, "‹ Prev") {
|
||
t.Fatal("last page must offer Prev")
|
||
}
|
||
if !mybotsHasButton(page2, "@mb12_bot") {
|
||
t.Fatalf("page 2 missing @mb12_bot: %+v", page2.ReplyMarkup)
|
||
}
|
||
// The pager edits one message in place rather than piling up new ones.
|
||
if page2.ID != page1.ID {
|
||
t.Fatalf("pager sent a new message (%d -> %d), want in-place edit", page1.ID, page2.ID)
|
||
}
|
||
}
|
||
|
||
func TestMyBotsBotMenuAndBack(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2002")
|
||
makeBots(t, svc, owner.ID, 2)
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
_, menu := pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
|
||
if !strings.Contains(menu.Body, "@mb0_bot") {
|
||
t.Fatalf("bot menu body = %q", menu.Body)
|
||
}
|
||
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
|
||
if !mybotsHasButton(menu, want) {
|
||
t.Fatalf("bot menu missing %q: %+v", want, menu.ReplyMarkup)
|
||
}
|
||
}
|
||
_, back := pressBotFather(t, svc, messages, owner.ID, "Back to bots")
|
||
if !strings.Contains(back.Body, "Choose a bot") {
|
||
t.Fatalf("back reply = %q", back.Body)
|
||
}
|
||
}
|
||
|
||
func TestBotFatherStartWithBotOpensItsMenu(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2011")
|
||
makeBots(t, svc, owner.ID, 2)
|
||
|
||
// "/start <bot>" is the "Manage Bot" deep link: it lands on the per-bot menu.
|
||
body := sendToBotFather(t, svc, messages, owner, "/start mb0_bot")
|
||
if !strings.Contains(body, "@mb0_bot") || !strings.Contains(body, "What do you want to do?") {
|
||
t.Fatalf("/start mb0_bot reply = %q", body)
|
||
}
|
||
menu := botFatherUserReply(t, messages, owner.ID)
|
||
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
|
||
if !mybotsHasButton(menu, want) {
|
||
t.Fatalf("start menu missing %q: %+v", want, menu.ReplyMarkup)
|
||
}
|
||
}
|
||
// The buttons are live (state was saved), so Edit Bot works from here.
|
||
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||
if !strings.Contains(edit.Body, "@mb0_bot") {
|
||
t.Fatalf("edit menu after /start = %q", edit.Body)
|
||
}
|
||
|
||
// A leading @ and an unknown/foreign bot fall back to the greeting.
|
||
if body := sendToBotFather(t, svc, messages, owner, "/start @mb1_bot"); !strings.Contains(body, "@mb1_bot") {
|
||
t.Fatalf("/start @mb1_bot reply = %q", body)
|
||
}
|
||
if body := sendToBotFather(t, svc, messages, owner, "/start not_a_real_bot"); !strings.Contains(body, "create a new bot") {
|
||
t.Fatalf("/start unknown reply = %q, want greeting", body)
|
||
}
|
||
if body := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(body, "create a new bot") {
|
||
t.Fatalf("bare /start reply = %q, want greeting", body)
|
||
}
|
||
}
|
||
|
||
func TestMyBotsTokenAndRevoke(t *testing.T) {
|
||
svc, users, bots, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2003")
|
||
created, token, err := svc.CreateBot(context.Background(), owner.ID, "Tok Bot", "tok_mb_bot")
|
||
if err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
pressBotFather(t, svc, messages, owner.ID, "@tok_mb_bot")
|
||
_, tokenScreen := pressBotFather(t, svc, messages, owner.ID, "API Token")
|
||
if !strings.Contains(tokenScreen.Body, token) {
|
||
t.Fatalf("token screen %q missing token %q", tokenScreen.Body, token)
|
||
}
|
||
|
||
pressBotFather(t, svc, messages, owner.ID, "Revoke current token")
|
||
_, revoked := pressBotFather(t, svc, messages, owner.ID, "Yes, revoke")
|
||
if strings.Contains(revoked.Body, token) {
|
||
t.Fatalf("revoke screen still shows the old token: %q", revoked.Body)
|
||
}
|
||
if !strings.Contains(revoked.Body, "has been revoked") {
|
||
t.Fatalf("revoke screen = %q", revoked.Body)
|
||
}
|
||
profile, _, err := bots.GetBot(context.Background(), created.ID)
|
||
if err != nil {
|
||
t.Fatalf("get bot: %v", err)
|
||
}
|
||
if domain.FormatBotToken(created.ID, profile.TokenSecret) == token {
|
||
t.Fatal("token was not rotated")
|
||
}
|
||
}
|
||
|
||
func TestMyBotsSettingsToggles(t *testing.T) {
|
||
svc, users, bots, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2004")
|
||
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Cfg Bot", "cfg_mb_bot")
|
||
if err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
ctx := context.Background()
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
pressBotFather(t, svc, messages, owner.ID, "@cfg_mb_bot")
|
||
_, settings := pressBotFather(t, svc, messages, owner.ID, "Bot Settings")
|
||
if !mybotsHasButton(settings, "Inline Mode: off") {
|
||
t.Fatalf("settings screen: %+v", settings.ReplyMarkup)
|
||
}
|
||
|
||
_, afterInline := pressBotFather(t, svc, messages, owner.ID, "Inline Mode:")
|
||
if !mybotsHasButton(afterInline, "Inline Mode: on") {
|
||
t.Fatalf("inline not toggled on: %+v", afterInline.ReplyMarkup)
|
||
}
|
||
profile, _, _ := bots.GetBot(ctx, created.ID)
|
||
if profile.InlinePlaceholder == "" {
|
||
t.Fatal("inline placeholder not set after toggle")
|
||
}
|
||
|
||
privacyBefore := mybotsHasButton(afterInline, "Group Privacy: on")
|
||
_, afterPrivacy := pressBotFather(t, svc, messages, owner.ID, "Group Privacy:")
|
||
if mybotsHasButton(afterPrivacy, "Group Privacy: on") == privacyBefore {
|
||
t.Fatalf("privacy not toggled: %+v", afterPrivacy.ReplyMarkup)
|
||
}
|
||
privProfile, _, _ := bots.GetBot(ctx, created.ID)
|
||
if (!privProfile.ChatHistory) == privacyBefore {
|
||
t.Fatal("privacy flag not flipped in the store")
|
||
}
|
||
|
||
_, afterGroups := pressBotFather(t, svc, messages, owner.ID, "Allow Groups:")
|
||
if !mybotsHasButton(afterGroups, "Allow Groups: off") {
|
||
t.Fatalf("groups not toggled off: %+v", afterGroups.ReplyMarkup)
|
||
}
|
||
profile, _, _ = bots.GetBot(ctx, created.ID)
|
||
if !profile.Nochats {
|
||
t.Fatal("nochats not set after toggling groups off")
|
||
}
|
||
}
|
||
|
||
func TestMyBotsEditNameHandsOffToValueInput(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2005")
|
||
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Old Name", "edit_mb_bot")
|
||
if err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
pressBotFather(t, svc, messages, owner.ID, "@edit_mb_bot")
|
||
pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Name")
|
||
if answer.Message != "" {
|
||
t.Fatalf("edit-name click alerted: %q", answer.Message)
|
||
}
|
||
if !strings.Contains(prompt.Body, "new name") {
|
||
t.Fatalf("edit-name prompt = %q", prompt.Body)
|
||
}
|
||
|
||
reply := sendToBotFather(t, svc, messages, owner, "Shiny New Name")
|
||
if !strings.Contains(reply, "Name updated") {
|
||
t.Fatalf("set name reply = %q", reply)
|
||
}
|
||
name, _, _, err := svc.GetBotInfo(context.Background(), created.ID)
|
||
if err != nil {
|
||
t.Fatalf("get bot info: %v", err)
|
||
}
|
||
if name != "Shiny New Name" {
|
||
t.Fatalf("bot name = %q, want updated", name)
|
||
}
|
||
}
|
||
|
||
// deletableBotStore adds a real DeleteBotAccount (memory.BotStore has none) so
|
||
// the /mybots delete flow can be exercised end to end.
|
||
type deletableBotStore struct {
|
||
*memory.BotStore
|
||
deleted map[int64]bool
|
||
}
|
||
|
||
func (d *deletableBotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
|
||
d.deleted[botUserID] = true
|
||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||
}
|
||
|
||
func (d *deletableBotStore) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
|
||
profiles, err := d.BotStore.ListBotsByOwner(ctx, ownerUserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := profiles[:0]
|
||
for _, p := range profiles {
|
||
if !d.deleted[p.BotUserID] {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func TestMyBotsEditBotShowsSummaryAndReturnsAfterEdit(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+2010")
|
||
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Enigma Network", "enigma_mb_bot"); err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
pressBotFather(t, svc, messages, owner.ID, "@enigma_mb_bot")
|
||
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||
for _, want := range []string{"Edit @enigma_mb_bot info.", "Name: Enigma Network", "About: 🚫", "Commands: no commands yet", "Botpic: 🚫"} {
|
||
if !strings.Contains(edit.Body, want) {
|
||
t.Fatalf("edit summary missing %q:\n%s", want, edit.Body)
|
||
}
|
||
}
|
||
for _, want := range []string{"Edit Name", "Edit About", "Edit Botpic", "Back to bot", "Bots list"} {
|
||
if !mybotsHasButton(edit, want) {
|
||
t.Fatalf("edit menu missing button %q: %+v", want, edit.ReplyMarkup)
|
||
}
|
||
}
|
||
// The @mention is a tappable entity, not plain text.
|
||
if !hasMentionEntity(edit, "@enigma_mb_bot") {
|
||
t.Fatalf("no mention entity for @enigma_mb_bot: %+v", edit.Entities)
|
||
}
|
||
|
||
pressBotFather(t, svc, messages, owner.ID, "Edit About")
|
||
sendToBotFather(t, svc, messages, owner, "we host things")
|
||
after := botFatherUserReply(t, messages, owner.ID)
|
||
if !strings.Contains(after.Body, "About section updated") || !strings.Contains(after.Body, "Edit @enigma_mb_bot info.") {
|
||
t.Fatalf("post-edit reply is not the edit menu:\n%s", after.Body)
|
||
}
|
||
if !strings.Contains(after.Body, "About: we host things") {
|
||
t.Fatalf("edit menu did not refresh the About value:\n%s", after.Body)
|
||
}
|
||
// The bug this guards: pressing another field on the refreshed menu must work,
|
||
// not report the button as expired.
|
||
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Botpic")
|
||
if answer.Alert {
|
||
t.Fatalf("Edit Botpic on the refreshed menu alerted: %q", answer.Message)
|
||
}
|
||
if !strings.Contains(prompt.Body, "profile picture") {
|
||
t.Fatalf("Edit Botpic prompt = %q", prompt.Body)
|
||
}
|
||
}
|
||
|
||
func TestMyBotsDeleteBot(t *testing.T) {
|
||
users := memory.NewUserStore()
|
||
store := &deletableBotStore{BotStore: memory.NewBotStore(users), deleted: map[int64]bool{}}
|
||
messages := memory.NewMessageStore(memory.NewDialogStore())
|
||
svc := NewService(users, store, messages)
|
||
svc.SetRouterHooks(&captureRevoker{})
|
||
owner := newOwner(t, users, "+2006")
|
||
makeBots(t, svc, owner.ID, 2)
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||
pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
|
||
pressBotFather(t, svc, messages, owner.ID, "Delete Bot")
|
||
_, afterDelete := pressBotFather(t, svc, messages, owner.ID, "Yes, delete")
|
||
if !strings.Contains(afterDelete.Body, "Deleted @mb0_bot") {
|
||
t.Fatalf("after delete body = %q", afterDelete.Body)
|
||
}
|
||
if mybotsHasButton(afterDelete, "@mb0_bot") {
|
||
t.Fatal("deleted bot still listed")
|
||
}
|
||
if !mybotsHasButton(afterDelete, "@mb1_bot") {
|
||
t.Fatalf("surviving bot dropped: %+v", afterDelete.ReplyMarkup)
|
||
}
|
||
}
|
||
|
||
func TestMyBotsForeignAndStaleTokensRefused(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
alice := newOwner(t, users, "+2007")
|
||
bob := newOwner(t, users, "+2008")
|
||
makeBotsP(t, svc, alice.ID, "alice", 1)
|
||
makeBotsP(t, svc, bob.ID, "bob", 1)
|
||
|
||
// Alice opens her menu; Bob replays one of her tokens against his own dialog.
|
||
sendToBotFather(t, svc, messages, alice, "/mybots")
|
||
aliceMenu := botFatherUserReply(t, messages, alice.ID)
|
||
var aliceToken []byte
|
||
for _, row := range aliceMenu.ReplyMarkup.Inline {
|
||
for _, b := range row {
|
||
if len(b.Data) > 0 {
|
||
aliceToken = append([]byte(nil), b.Data...)
|
||
}
|
||
}
|
||
}
|
||
sendToBotFather(t, svc, messages, bob, "/mybots")
|
||
answer, _ := pressBotFatherData(t, svc, messages, bob.ID, aliceToken)
|
||
if !answer.Alert || answer.Message == "" {
|
||
t.Fatalf("replayed foreign token answer = %+v, want expired-button alert", answer)
|
||
}
|
||
|
||
// A stale generation of Alice's own buttons is refused too.
|
||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 2
|
||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 3
|
||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 4 -> gen 1 pruned
|
||
answer, _ = pressBotFatherData(t, svc, messages, alice.ID, aliceToken)
|
||
if !answer.Alert {
|
||
t.Fatalf("stale token answer = %+v, want expired-button alert", answer)
|
||
}
|
||
}
|