owpengram-server/internal/app/bots/mybots_test.go
Astra 86f9b61336 botfather: /start <bot> opens that bot's menu
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.
2026-09-09 15:03:53 +01:00

475 lines
17 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 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)
}
}