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 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) } }