owpengram-server/internal/app/bots/mybots_test.go
Astra f474a360d7 Interactive /mybots menu for @BotFather
Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
2026-09-14 11:52:52 +01:00

387 lines
13 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
}
// 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 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)
}
}