Ctrl K
ComunidadGuías…

internal/app/bots/botfather_picker_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package bots

import (
	"context"
	"strings"
	"testing"

	"telesrv/internal/domain"
	"telesrv/internal/store/memory"
)

// lastBotFatherReply returns the latest message from BotFather in the owner's inbox (without sending a new message).
func lastBotFatherReply(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:   100,
	})
	if err != nil {
		t.Fatalf("list 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 history")
	}
	return latest
}

// botFatherPickerButtonTexts returns the button texts in the reply keyboard (the keyboard below the input box).
func botFatherPickerButtonTexts(t *testing.T, msg domain.Message) []string {
	t.Helper()
	if msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard {
		t.Fatalf("reply %q does not carry a reply keyboard: %+v", msg.Body, msg.ReplyMarkup)
	}
	if !msg.ReplyMarkup.Resize || !msg.ReplyMarkup.SingleUse {
		t.Fatalf("reply keyboard flags = resize:%t single_use:%t, want both true",
			msg.ReplyMarkup.Resize, msg.ReplyMarkup.SingleUse)
	}
	var out []string
	for _, row := range msg.ReplyMarkup.Keyboard {
		for _, button := range row {
			if button.Type != domain.MarkupButtonText {
				t.Fatalf("picker button %q type = %q, want a plain text button", button.Text, button.Type)
			}
			out = append(out, button.Text)
		}
	}
	return out
}

func containsString(list []string, want string) bool {
	for _, item := range list {
		if item == want {
			return true
		}
	}
	return false
}

// The bot picker keyboard is a reply keyboard: tapping a button equals sending @username as plain text to BotFather.
func TestBotFatherSetNameUsesReplyKeyboard(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2020")
	bot := makeBot(t, svc, owner, "Button Bot", "button_name_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setname")
	if picker.Body != "Choose a bot to change name." {
		t.Fatalf("/setname reply = %q, want the picker prompt only", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_name_bot" {
		t.Fatalf("/setname keyboard = %v, want [@button_name_bot]", got)
	}

	// Tapping a reply keyboard button = the client sends that button's text.
	if reply := sendToBotFather(t, svc, messages, owner, "@button_name_bot"); reply != "OK. Send me the new name for your bot." {
		t.Fatalf("after picking a bot reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "Renamed By Button"); reply != "Success! Name updated. /help" {
		t.Fatalf("setname reply = %q", reply)
	}
	if u, _, _ := users.ByID(ctx, bot.ID); u.FirstName != "Renamed By Button" {
		t.Fatalf("bot first_name = %q, want renamed", u.FirstName)
	}
}

// /setdescription uses the same keyboard picker as /setname, with text matching the official BotFather.
func TestBotFatherSetDescriptionUsesReplyKeyboard(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2023")
	bot := makeBot(t, svc, owner, "Description Bot", "button_desc_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setdescription")
	if picker.Body != "Choose a bot to change description." {
		t.Fatalf("/setdescription reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_desc_bot" {
		t.Fatalf("/setdescription keyboard = %v", got)
	}

	const prompt = "OK. Send me the new description for the bot. People will see this description when they open a chat with your bot, in a block titled 'What can this bot do?'."
	if reply := sendToBotFather(t, svc, messages, owner, "@button_desc_bot"); reply != prompt {
		t.Fatalf("after picking a bot reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "123"); reply != "Success! Description updated. You will be able to see the changes within a few minutes. /help" {
		t.Fatalf("setdescription reply = %q", reply)
	}
	_, _, description, err := svc.GetBotInfo(ctx, bot.ID)
	if err != nil || description != "123" {
		t.Fatalf("stored description = %q, err=%v", description, err)
	}
}

// /setabouttext uses the same keyboard picker as /setname and /setdescription, with text matching the official BotFather.
func TestBotFatherSetAboutUsesReplyKeyboard(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2024")
	bot := makeBot(t, svc, owner, "About Bot", "button_about_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setabouttext")
	if picker.Body != "Choose a bot to change the about section." {
		t.Fatalf("/setabouttext reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_about_bot" {
		t.Fatalf("/setabouttext keyboard = %v", got)
	}

	const prompt = "OK. Send me the new 'About' text. People will see this text on the bot's profile page and it will be sent together with a link to your bot when they share it with someone."
	if reply := sendToBotFather(t, svc, messages, owner, "@button_about_bot"); reply != prompt {
		t.Fatalf("after picking a bot reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "123"); reply != "Success! About section updated. /help" {
		t.Fatalf("setabouttext reply = %q", reply)
	}
	_, about, _, err := svc.GetBotInfo(ctx, bot.ID)
	if err != nil || about != "123" {
		t.Fatalf("stored about = %q, err=%v", about, err)
	}
}

// /setcommands' keyboard picker and format text match the official BotFather.
func TestBotFatherSetCommandsUsesReplyKeyboard(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2025")
	bot := makeBot(t, svc, owner, "Commands Bot", "button_cmd_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setcommands")
	if picker.Body != "Choose a bot to change the list of commands." {
		t.Fatalf("/setcommands reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_cmd_bot" {
		t.Fatalf("/setcommands keyboard = %v", got)
	}

	const prompt = "OK. Send me a list of commands for your bot. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to keep the list empty."
	if reply := sendToBotFather(t, svc, messages, owner, "@button_cmd_bot"); reply != prompt {
		t.Fatalf("after picking a bot reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "comand1 tsddsdd\ncommand2 fdfdfddfd"); reply != botFatherCommandListInvalidText() {
		t.Fatalf("malformed list reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "command1 - Description\ncommand2 - Another description"); reply != "Success! Command list updated. /help" {
		t.Fatalf("setcommands reply = %q", reply)
	}
	commands, err := svc.GetBotCommands(ctx, bot.ID)
	if err != nil || len(commands) != 2 || commands[0].Command != "command1" || commands[0].Description != "Description" {
		t.Fatalf("stored commands = %+v, err=%v", commands, err)
	}
}

// /token is also a keyboard picker; the token is returned as a code entity, so the client copies it with one tap.
func TestBotFatherTokenUsesReplyKeyboard(t *testing.T) {
	svc, users, bots, messages := newTestService(t)
	owner := newOwner(t, users, "+2026")
	bot := makeBot(t, svc, owner, "Token Button Bot", "button_token_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/token")
	if picker.Body != "Choose a bot to get Bot API access token." {
		t.Fatalf("/token reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_token_bot" {
		t.Fatalf("/token keyboard = %v", got)
	}

	sendToBotFather(t, svc, messages, owner, "@button_token_bot")
	profile, found, err := bots.GetBot(ctx, bot.ID)
	if err != nil || !found || profile.TokenSecret == "" {
		t.Fatalf("bot profile: found=%v err=%v", found, err)
	}
	token := domain.FormatBotToken(bot.ID, profile.TokenSecret)
	want := "You can use this token to access HTTP API:\n" + token +
		"\n\nFor a description of the Bot API, see this page: https://core.telegram.org/bots/api"
	reply := lastBotFatherReply(t, messages, owner.ID)
	if reply.Body != want {
		t.Fatalf("token reply = %q, want %q", reply.Body, want)
	}
	copied := false
	for _, entity := range reply.Entities {
		if entity.Type == domain.MessageEntityCode && reply.Body[entity.Offset:entity.Offset+entity.Length] == token {
			copied = true
		}
	}
	if !copied {
		t.Fatalf("token reply entities = %+v, want a code entity around %q", reply.Entities, token)
	}
}

// Keyboard has two columns: 3 bots are laid out as 2 + 1.
func TestBotFatherPickerLaysOutTwoButtonsPerRow(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2027")
	for _, username := range []string{"row_one_bot", "row_two_bot", "row_three_bot"} {
		makeBot(t, svc, owner, "Row Bot", username)
	}

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setname")
	botFatherPickerButtonTexts(t, picker) // validate the type and resize/single_use
	rows := picker.ReplyMarkup.Keyboard
	if len(rows) != 2 || len(rows[0]) != 2 || len(rows[1]) != 1 {
		t.Fatalf("keyboard rows = %+v, want [2 1]", rows)
	}
	if rows[0][0].Text != "@row_one_bot" || rows[0][1].Text != "@row_two_bot" || rows[1][0].Text != "@row_three_bot" {
		t.Fatalf("keyboard = %+v, want creation order", rows)
	}
}

// /setinline: keyboard picker + the official placeholder prompt.
func TestBotFatherSetInlineUsesReplyKeyboard(t *testing.T) {
	svc, users, bots, messages := newTestService(t)
	owner := newOwner(t, users, "+2028")
	bot := makeBot(t, svc, owner, "Inline Bot", "button_inline_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setinline")
	if picker.Body != "Choose a bot to change inline queries status." {
		t.Fatalf("/setinline reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_inline_bot" {
		t.Fatalf("/setinline keyboard = %v", got)
	}
	const prompt = "This will enable inline queries for your bot. https://core.telegram.org/bots/inline. Use /empty to disable.\n\nTo enable inline mode, please send me the placeholder message for queries to your bot. People will see this placeholder whenever they type @button_inline_bot in the message field – in any chat. Tell them what they can get from your bot (e.g., Search GIFs...)."
	if reply := sendToBotFather(t, svc, messages, owner, "@button_inline_bot"); reply != prompt {
		t.Fatalf("after picking a bot reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "Search GIFs"); reply != "Success! Inline settings updated. /help" {
		t.Fatalf("setinline reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search GIFs" {
		t.Fatalf("inline placeholder = %q", p.InlinePlaceholder)
	}
}

// /setinlinegeo: lists only bots with inline mode enabled, shows the current value, and an Enable/Disable keyboard.
func TestBotFatherSetInlineGeoUsesReplyKeyboard(t *testing.T) {
	svc, users, bots, messages := newTestService(t)
	owner := newOwner(t, users, "+2029")
	inlineBot := makeBot(t, svc, owner, "Inline On", "geo_inline_bot")
	plainBot := makeBot(t, svc, owner, "Inline Off", "geo_plain_bot")
	ctx := context.Background()
	if _, err := svc.SetInlinePlaceholder(ctx, inlineBot.ID, "Search GIFs"); err != nil {
		t.Fatalf("enable inline: %v", err)
	}

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setinlinegeo")
	if picker.Body != "Choose a bot to change inline location request settings." {
		t.Fatalf("/setinlinegeo reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); !containsString(got, "@geo_inline_bot") || containsString(got, "@geo_plain_bot") {
		t.Fatalf("/setinlinegeo keyboard = %v, want only the inline-enabled bot", got)
	}

	status := latestBotFatherMessage(t, svc, messages, owner, "@geo_inline_bot")
	const prompt = "If enabled, @geo_inline_bot will ask for permission to get the user's location each time they send a request.\nCurrent value is: DISABLED."
	if status.Body != prompt {
		t.Fatalf("setinlinegeo prompt = %q", status.Body)
	}
	if got := botFatherPickerButtonTexts(t, status); len(got) != 2 || got[0] != "Enable" || got[1] != "Disable" {
		t.Fatalf("setinlinegeo keyboard = %v, want [Enable Disable]", got)
	}

	if reply := sendToBotFather(t, svc, messages, owner, "Enable"); reply != "Success! Inline location request settings updated. New status is ENABLED /help" {
		t.Fatalf("setinlinegeo enable reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, inlineBot.ID); !p.InlineGeo {
		t.Fatal("inline_geo = false, want enabled")
	}
	if p, _, _ := bots.GetBot(ctx, plainBot.ID); p.InlineGeo {
		t.Fatal("the non-inline bot was touched")
	}
}

// /setjoingroups: shows the current status + an Enable/Disable keyboard.
func TestBotFatherSetJoinGroupsUsesReplyKeyboard(t *testing.T) {
	svc, users, bots, messages := newTestService(t)
	owner := newOwner(t, users, "+2030")
	bot := makeBot(t, svc, owner, "Join Bot", "button_join_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setjoingroups")
	if picker.Body != "Choose a bot to change group membership settings." {
		t.Fatalf("/setjoingroups reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_join_bot" {
		t.Fatalf("/setjoingroups keyboard = %v", got)
	}

	const enabledPrompt = "'Enable' - bot can be added to groups.\n'Disable' - block group invitations, the bot can't be added to groups.\nCurrent status is: ENABLED"
	status := latestBotFatherMessage(t, svc, messages, owner, "@button_join_bot")
	if status.Body != enabledPrompt {
		t.Fatalf("setjoingroups prompt = %q", status.Body)
	}
	if got := botFatherPickerButtonTexts(t, status); len(got) != 2 || got[0] != "Enable" || got[1] != "Disable" {
		t.Fatalf("setjoingroups keyboard = %v, want [Enable Disable]", got)
	}

	if reply := sendToBotFather(t, svc, messages, owner, "Disable"); reply != "Success! The new status is: DISABLED. /help" {
		t.Fatalf("setjoingroups disable reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats {
		t.Fatal("nochats = false, want true after disable")
	}

	sendToBotFather(t, svc, messages, owner, "/setjoingroups")
	disabledStatus := latestBotFatherMessage(t, svc, messages, owner, "@button_join_bot")
	const disabledPrompt = "'Enable' - bot can be added to groups.\n'Disable' - block group invitations, the bot can't be added to groups.\nCurrent status is: DISABLED"
	if disabledStatus.Body != disabledPrompt {
		t.Fatalf("setjoingroups prompt after disable = %q", disabledStatus.Body)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "Enable"); reply != "Success! The new status is: ENABLED. /help" {
		t.Fatalf("setjoingroups enable reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, bot.ID); p.Nochats {
		t.Fatal("nochats = true, want false after enable")
	}
}

// /setprivacy: shows the current status + an Enable/Disable keyboard.
func TestBotFatherSetPrivacyUsesReplyKeyboard(t *testing.T) {
	svc, users, bots, messages := newTestService(t)
	owner := newOwner(t, users, "+2031")
	bot := makeBot(t, svc, owner, "Privacy Bot", "button_privacy_bot")
	ctx := context.Background()

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setprivacy")
	if picker.Body != "Choose a bot to change group messages settings." {
		t.Fatalf("/setprivacy reply = %q", picker.Body)
	}
	if got := botFatherPickerButtonTexts(t, picker); len(got) != 1 || got[0] != "@button_privacy_bot" {
		t.Fatalf("/setprivacy keyboard = %v", got)
	}

	const enabledPrompt = "'Enable' - your bot will only receive messages that either start with the '/' symbol or mention the bot by username.\n'Disable' - your bot will receive all messages that people send to groups.\nCurrent status is: ENABLED"
	status := latestBotFatherMessage(t, svc, messages, owner, "@button_privacy_bot")
	if status.Body != enabledPrompt {
		t.Fatalf("setprivacy prompt = %q", status.Body)
	}
	if got := botFatherPickerButtonTexts(t, status); len(got) != 2 || got[0] != "Enable" || got[1] != "Disable" {
		t.Fatalf("setprivacy keyboard = %v, want [Enable Disable]", got)
	}

	if reply := sendToBotFather(t, svc, messages, owner, "Disable"); reply != "Success! The new status is: DISABLED. /help" {
		t.Fatalf("setprivacy disable reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, bot.ID); !p.ChatHistory {
		t.Fatal("chat_history = false, want true after disabling privacy")
	}

	sendToBotFather(t, svc, messages, owner, "/setprivacy")
	disabledStatus := latestBotFatherMessage(t, svc, messages, owner, "@button_privacy_bot")
	const disabledPrompt = "'Enable' - your bot will only receive messages that either start with the '/' symbol or mention the bot by username.\n'Disable' - your bot will receive all messages that people send to groups.\nCurrent status is: DISABLED"
	if disabledStatus.Body != disabledPrompt {
		t.Fatalf("setprivacy prompt after disable = %q", disabledStatus.Body)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "Enable"); reply != "Success! The new status is: ENABLED. /help" {
		t.Fatalf("setprivacy enable reply = %q", reply)
	}
	if p, _, _ := bots.GetBot(ctx, bot.ID); p.ChatHistory {
		t.Fatal("chat_history = true, want false after enabling privacy")
	}
}

// The keyboard may only contain the owner's own bots; manually typing someone else's username cannot select it either.
func TestBotFatherPickerKeyboardOnlyListsOwnBots(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2021")
	other := newOwner(t, users, "+2022")
	makeBot(t, svc, owner, "Own Bot", "picker_own_bot")
	makeBot(t, svc, other, "Other Bot", "picker_other_bot")

	picker := latestBotFatherMessage(t, svc, messages, owner, "/setname")
	if got := botFatherPickerButtonTexts(t, picker); !containsString(got, "@picker_own_bot") || containsString(got, "@picker_other_bot") {
		t.Fatalf("/setname keyboard = %v, want only the owner's bot", got)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "@picker_other_bot"); reply != "I don't see that bot among yours. Send the username of one of your bots, or /cancel." {
		t.Fatalf("foreign bot reply = %q", reply)
	}
}

// When /setinlinegeo cannot pick any bot, the previous command's step must be cleared: otherwise, after the user
// sees "no inline bot", the next text they casually send would be treated by the old step as their own input (it
// once wrote the /setname value text directly as the bot name).
func TestBotFatherInlineGeoPickerDropsStaleStep(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	owner := newOwner(t, users, "+2032")
	bot := makeBot(t, svc, owner, "Stale Step Bot", "stale_step_bot")
	ctx := context.Background()

	sendToBotFather(t, svc, messages, owner, "/setname")
	sendToBotFather(t, svc, messages, owner, "@stale_step_bot") // enter the /setname value step

	if reply := sendToBotFather(t, svc, messages, owner, "/setinlinegeo"); reply != "You don't have any bots with inline mode enabled. Use /setinline to enable it first." {
		t.Fatalf("/setinlinegeo reply = %q", reply)
	}
	if reply := sendToBotFather(t, svc, messages, owner, "Renamed By Accident"); reply != "I can only help you create and manage bots. Send /help for a list of commands." {
		t.Fatalf("follow-up reply = %q, want the no-active-command notice", reply)
	}
	if u, _, _ := users.ByID(ctx, bot.ID); u.FirstName != "Stale Step Bot" {
		t.Fatalf("bot name = %q, want untouched", u.FirstName)
	}
}

// With a public Bot API URL configured, BotFather tells the owner where to
// send requests next to the token; without one the reply is unchanged.
func TestBotFatherTokenShowsConfiguredBotAPIURL(t *testing.T) {
	svc, users, _, messages := newTestService(t)
	WithBotAPIURL(" https://api.example.com/ ")(svc)
	owner := newOwner(t, users, "+2027")
	makeBot(t, svc, owner, "Hint Bot", "hint_url_bot")

	latestBotFatherMessage(t, svc, messages, owner, "/token")
	sendToBotFather(t, svc, messages, owner, "@hint_url_bot")
	reply := lastBotFatherReply(t, messages, owner.ID)
	if !strings.Contains(reply.Body, "\n\nBot API on this server: https://api.example.com/bot<token>/<method>\n\n") {
		t.Fatalf("token reply = %q, want the configured Bot API URL", reply.Body)
	}
}