Ctrl K
ComunidadGuías…

internal/mtprotoedge/bot_e2e_test.go

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

Archivo original ↗Preguntar a KiBot
package mtprotoedge

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"net"
	"regexp"
	"strings"
	"testing"
	"time"

	"go.uber.org/zap/zaptest"

	"github.com/gotd/log/logzap"
	"github.com/iamxvbaba/td/clock"
	"github.com/iamxvbaba/td/exchange"
	"github.com/iamxvbaba/td/session"
	"github.com/iamxvbaba/td/telegram"
	"github.com/iamxvbaba/td/telegram/dcs"
	"github.com/iamxvbaba/td/tg"
	"github.com/iamxvbaba/td/tgerr"
	"github.com/iamxvbaba/td/transport"

	"telesrv/internal/app/account"
	"telesrv/internal/app/auth"
	botsapp "telesrv/internal/app/bots"
	"telesrv/internal/app/contacts"
	"telesrv/internal/app/dialogs"
	"telesrv/internal/app/help"
	"telesrv/internal/app/langpack"
	messageapp "telesrv/internal/app/messages"
	"telesrv/internal/app/updates"
	"telesrv/internal/app/users"
	"telesrv/internal/domain"
	"telesrv/internal/rpc"
	"telesrv/internal/store/memory"
)

var botTokenRe = regexp.MustCompile(`(\d+):([A-Za-z0-9_-]{35})`)

// TestBotFatherCreateAndBotLoginFlow is the bot main-chain end-to-end verification:
// TestBotManagementRPCFlow verifies the P2 bots.* management RPC end to end:
// after the bot itself calls setBotCommands/setBotInfo/setBotMenuButton, getFullUser
// reflects the new values and bot_info_version increases monotonically; from the
// owner's perspective getFullUser(bot) carries bot_can_edit and the owner can
// modify the bot name via bot:InputUser.
func TestBotManagementRPCFlow(t *testing.T) {
	const (
		dc   = 2
		code = "12345"
	)
	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatalf("gen rsa: %v", err)
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	tcpAddr := ln.Addr().(*net.TCPAddr)

	userStore := memory.NewUserStore()
	authzStore := memory.NewAuthorizationStore()
	authKeyStore := memory.NewAuthKeyStore()
	helpStore := memory.NewHelpStore()
	langPackStore := memory.NewLangPackStore()
	dialogStore := memory.NewDialogStore()
	messageStore := memory.NewMessageStore(dialogStore)
	botStore := memory.NewBotStore(userStore)
	botsService := botsapp.NewService(userStore, botStore, messageStore,
		botsapp.WithLogger(zaptest.NewLogger(t).Named("bots")))
	activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
	deps := rpc.Deps{
		Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
			memory.NewTempAuthKeyBindingStore(authKeyStore), code, auth.WithBotLogin(botStore)),
		Account:  account.NewService(memory.NewPasswordStore()),
		Help:     help.NewService(helpStore, helpStore),
		Users:    users.NewService(userStore),
		Updates:  updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
		Contacts: contacts.NewService(memory.NewContactStore()),
		Dialogs:  dialogs.NewService(dialogStore),
		Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithBotResponder(botsService)),
		Bots:     botsService,
		LangPack: langpack.NewService(langPackStore),
		Sessions: activeSessions,
	}
	router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
	botsService.SetRouterHooks(router)
	botsService.SetTextDraftPusher(router)
	srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ctx, ln) }()

	newClient := func(storage *session.StorageMemory) *telegram.Client {
		opts := telegram.Options{
			PublicKeys:     []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
			Resolver:       dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
			DCList:         dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
			Logger:         logzap.New(zaptest.NewLogger(t).Named("client")),
			SessionStorage: storage,
			UpdateHandler:  telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
		}
		return telegram.NewClient(1, "hash", opts)
	}

	// Owner registers.
	ownerStorage := &session.StorageMemory{}
	var owner tg.User
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: "+15550003001", APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}})
			if err != nil {
				return err
			}
			hash := sent.(*tg.AuthSentCode).PhoneCodeHash
			if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "+15550003001", PhoneCodeHash: hash, PhoneCode: code}); err != nil {
				return err
			}
			res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: "+15550003001", PhoneCodeHash: hash, FirstName: "Owner"})
			if err != nil {
				return err
			}
			owner = *res.(*tg.AuthAuthorization).User.(*tg.User)
			return nil
		}); err != nil {
			t.Fatalf("owner signUp: %v", err)
		}
	}

	// Create the bot directly via the service (bypassing the BotFather conversation, focusing on the management RPC).
	botUser, botToken, err := botsService.CreateBot(context.Background(), owner.ID, "Mgmt Bot", "mgmt_test_bot")
	if err != nil {
		t.Fatalf("create bot: %v", err)
	}

	getFullSelfBotInfo := func(raw *tg.Client) (tg.BotInfo, *tg.User) {
		t.Helper()
		full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{})
		if err != nil {
			t.Fatalf("getFullUser self: %v", err)
		}
		bi, ok := full.FullUser.GetBotInfo()
		if !ok {
			t.Fatalf("self userFull lacks bot_info")
		}
		return bi, full.Users[0].(*tg.User)
	}

	// The bot logs in and calls the management RPC.
	botStorage := &session.StorageMemory{}
	var verAfterCommands int
	{
		client := newClient(botStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			if _, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{APIID: 1, APIHash: "hash", BotAuthToken: botToken}); err != nil {
				return err
			}
			_, self0 := getFullSelfBotInfo(raw)
			v0, _ := self0.GetBotInfoVersion()

			// setBotCommands(default scope)。
			ok, err := raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
				Scope:    &tg.BotCommandScopeDefault{},
				LangCode: "",
				Commands: []tg.BotCommand{{Command: "start", Description: "begin"}, {Command: "help", Description: "show help"}},
			})
			if err != nil || !ok {
				t.Fatalf("setBotCommands = %v,%v", ok, err)
			}
			bi, self1 := getFullSelfBotInfo(raw)
			cmds, _ := bi.GetCommands()
			if len(cmds) != 2 || cmds[0].Command != "start" {
				t.Fatalf("bot_info commands = %+v, want [start,help]", cmds)
			}
			v1, _ := self1.GetBotInfoVersion()
			if v1 <= v0 {
				t.Fatalf("bot_info_version not bumped after setBotCommands: %d -> %d", v0, v1)
			}
			verAfterCommands = v1

			// Read back getBotCommands.
			got, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{Scope: &tg.BotCommandScopeDefault{}})
			if err != nil || len(got) != 2 {
				t.Fatalf("getBotCommands = %+v, %v", got, err)
			}

			// setBotMenuButton(webview)。
			if ok, err := raw.BotsSetBotMenuButton(ctx, &tg.BotsSetBotMenuButtonRequest{
				UserID: &tg.InputUserSelf{},
				Button: &tg.BotMenuButton{Text: "Open", URL: "https://example.com/app"},
			}); err != nil || !ok {
				t.Fatalf("setBotMenuButton = %v,%v", ok, err)
			}
			bi2, _ := getFullSelfBotInfo(raw)
			mb, _ := bi2.GetMenuButton()
			if btn, ok := mb.(*tg.BotMenuButton); !ok || btn.URL != "https://example.com/app" {
				t.Fatalf("menu button = %#v, want webview", mb)
			}

			// setBotInfo(description) by the bot itself (without the bot parameter); Description is flag.1 and must be set via SetDescription.
			infoReq := &tg.BotsSetBotInfoRequest{LangCode: ""}
			infoReq.SetDescription("what I do")
			if ok, err := raw.BotsSetBotInfo(ctx, infoReq); err != nil || !ok {
				t.Fatalf("setBotInfo(description) = %v,%v", ok, err)
			}
			info, err := raw.BotsGetBotInfo(ctx, &tg.BotsGetBotInfoRequest{LangCode: ""})
			if err != nil || info.Description != "what I do" {
				t.Fatalf("getBotInfo = %#v, %v", info, err)
			}

			// The bot itself should not carry bot_can_edit (only the owner's perspective has it).
			_, selfU := getFullSelfBotInfo(raw)
			if selfU.GetBotCanEdit() {
				t.Fatalf("bot self carries bot_can_edit, want only owner view")
			}
			return nil
		}); err != nil {
			t.Fatalf("bot management flow: %v", err)
		}
	}

	// Owner's perspective: getFullUser(bot) carries bot_can_edit + commands; the owner modifies the name.
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "mgmt_test_bot"})
			if err != nil {
				return err
			}
			seen := resolved.Users[0].(*tg.User)
			botInput := &tg.InputUser{UserID: seen.ID, AccessHash: seen.AccessHash}
			full, err := raw.UsersGetFullUser(ctx, botInput)
			if err != nil {
				return err
			}
			ownerSeesBot := full.Users[0].(*tg.User)
			if !ownerSeesBot.GetBotCanEdit() {
				t.Fatalf("owner view bot_can_edit = false, want true")
			}
			if bi, ok := full.FullUser.GetBotInfo(); ok {
				if cmds, _ := bi.GetCommands(); len(cmds) != 2 {
					t.Fatalf("owner view bot commands = %d, want 2", len(cmds))
				}
			}
			// The owner modifies the bot name (with bot:InputUser).
			req := &tg.BotsSetBotInfoRequest{LangCode: ""}
			req.SetBot(botInput)
			req.SetName("Owner Renamed")
			if ok, err := raw.BotsSetBotInfo(ctx, req); err != nil || !ok {
				t.Fatalf("owner setBotInfo(name) = %v,%v", ok, err)
			}
			got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{botInput})
			if err != nil || len(got) != 1 {
				t.Fatalf("getUsers(bot) = %+v, %v", got, err)
			}
			if u := got[0].(*tg.User); u.FirstName != "Owner Renamed" {
				t.Fatalf("bot first_name = %q, want 'Owner Renamed'", u.FirstName)
			}
			return nil
		}); err != nil {
			t.Fatalf("owner management flow: %v", err)
		}
	}

	_ = botUser
	_ = verAfterCommands
	cancel()
	if err := <-serveErr; err != nil {
		t.Errorf("serve: %v", err)
	}
}

// User registers → resolveUsername(BotFather) → /newbot conversation to obtain a
// token → an external client logs in as the bot via auth.importBotAuthorization →
// the user and bot exchange messages → a wrong token gets ACCESS_TOKEN_INVALID.
func TestBotFatherCreateAndBotLoginFlow(t *testing.T) {
	const (
		dc   = 2
		code = "12345"
	)
	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatalf("gen rsa: %v", err)
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	tcpAddr := ln.Addr().(*net.TCPAddr)

	userStore := memory.NewUserStore()
	authzStore := memory.NewAuthorizationStore()
	authKeyStore := memory.NewAuthKeyStore()
	helpStore := memory.NewHelpStore()
	langPackStore := memory.NewLangPackStore()
	dialogStore := memory.NewDialogStore()
	messageStore := memory.NewMessageStore(dialogStore)
	botStore := memory.NewBotStore(userStore)
	botsService := botsapp.NewService(userStore, botStore, messageStore,
		botsapp.WithLogger(zaptest.NewLogger(t).Named("bots")))
	activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
	deps := rpc.Deps{
		Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
			memory.NewTempAuthKeyBindingStore(authKeyStore), code, auth.WithBotLogin(botStore)),
		Account:  account.NewService(memory.NewPasswordStore()),
		Help:     help.NewService(helpStore, helpStore),
		Users:    users.NewService(userStore),
		Updates:  updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
		Contacts: contacts.NewService(memory.NewContactStore()),
		Dialogs:  dialogs.NewService(dialogStore),
		Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithBotResponder(botsService)),
		Bots:     botsService,
		LangPack: langpack.NewService(langPackStore),
		Sessions: activeSessions,
	}
	router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
	botsService.SetRouterHooks(router)
	botsService.SetTextDraftPusher(router)
	srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ctx, ln) }()

	newClient := func(storage *session.StorageMemory) *telegram.Client {
		opts := telegram.Options{
			PublicKeys:     []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
			Resolver:       dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
			DCList:         dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
			Logger:         logzap.New(zaptest.NewLogger(t).Named("client")),
			SessionStorage: storage,
			UpdateHandler:  telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
		}
		return telegram.NewClient(1, "hash", opts)
	}

	messagesOf := func(history tg.MessagesMessagesClass) []tg.MessageClass {
		t.Helper()
		switch v := history.(type) {
		case *tg.MessagesMessages:
			return v.Messages
		case *tg.MessagesMessagesSlice:
			return v.Messages
		default:
			t.Fatalf("history = %T %+v, want messages", history, history)
			return nil
		}
	}

	// 1) Owner registers.
	ownerStorage := &session.StorageMemory{}
	var owner tg.User
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
				PhoneNumber: "+15550002001", APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{},
			})
			if err != nil {
				return err
			}
			hash := sent.(*tg.AuthSentCode).PhoneCodeHash
			if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
				PhoneNumber: "+15550002001", PhoneCodeHash: hash, PhoneCode: code,
			}); err != nil {
				return err
			}
			res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{
				PhoneNumber: "+15550002001", PhoneCodeHash: hash, FirstName: "Owner",
			})
			if err != nil {
				return err
			}
			owner = *res.(*tg.AuthAuthorization).User.(*tg.User)
			return nil
		}); err != nil {
			t.Fatalf("owner signUp: %v", err)
		}
	}

	// 2) The owner talks to BotFather to create a bot and obtain a token.
	var (
		botToken    string
		botUsername = "owner_e2e_bot"
		botUserID   int64
	)
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "BotFather"})
			if err != nil {
				return err
			}
			if len(resolved.Users) != 1 {
				t.Fatalf("resolve BotFather users = %d, want 1", len(resolved.Users))
			}
			botFather := resolved.Users[0].(*tg.User)
			if botFather.ID != domain.BotFatherUserID || !botFather.Bot {
				t.Fatalf("resolved BotFather = %+v, want bot flag with id %d", botFather, domain.BotFatherUserID)
			}
			if v, ok := botFather.GetBotInfoVersion(); !ok || v < 1 {
				t.Fatalf("BotFather bot_info_version = %d,%v, want >=1", v, ok)
			}
			if _, hasStatus := botFather.GetStatus(); hasStatus {
				t.Fatalf("BotFather carries status %+v, want none for bots", botFather.Status)
			}
			peer := &tg.InputPeerUser{UserID: botFather.ID, AccessHash: botFather.AccessHash}

			// userFull.bot_info must exist and user_id must match (a TDesktop P0 compatibility point).
			full, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: botFather.ID, AccessHash: botFather.AccessHash})
			if err != nil {
				return err
			}
			botInfo, ok := full.FullUser.GetBotInfo()
			if !ok {
				t.Fatalf("BotFather userFull lacks bot_info")
			}
			if id, ok := botInfo.GetUserID(); !ok || id != domain.BotFatherUserID {
				t.Fatalf("bot_info.user_id = %d,%v, want %d", id, ok, domain.BotFatherUserID)
			}
			if cmds, ok := botInfo.GetCommands(); !ok || len(cmds) == 0 {
				t.Fatalf("BotFather bot_info commands empty, want seeded commands")
			}

			randomID := int64(31001)
			// BotFather's reply arrives asynchronously (a goroutine); poll the top of the history until a new incoming reply appears.
			sendAndReply := func(text string) string {
				t.Helper()
				randomID++
				beforeTop := 0
				if h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 1}); err == nil {
					if m := messagesOf(h); len(m) > 0 {
						if msg, ok := m[0].(*tg.Message); ok {
							beforeTop = msg.ID
						}
					}
				}
				if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
					Peer: peer, Message: text, RandomID: randomID,
				}); err != nil {
					t.Fatalf("send %q: %v", text, err)
				}
				deadline := time.Now().Add(10 * time.Second)
				for {
					history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 5})
					if err != nil {
						t.Fatalf("history after %q: %v", text, err)
					}
					msgs := messagesOf(history)
					if len(msgs) > 0 {
						if top, ok := msgs[0].(*tg.Message); ok && !top.Out && top.ID > beforeTop {
							return top.Message
						}
					}
					if time.Now().After(deadline) {
						t.Fatalf("timed out waiting for BotFather reply to %q", text)
					}
					time.Sleep(50 * time.Millisecond)
				}
			}

			if reply := sendAndReply("/newbot"); !strings.Contains(reply, "choose a name") {
				t.Fatalf("/newbot reply = %q", reply)
			}
			if reply := sendAndReply("Owner E2E Bot"); !strings.Contains(reply, "username") {
				t.Fatalf("name reply = %q", reply)
			}
			reply := sendAndReply(botUsername)
			match := botTokenRe.FindStringSubmatch(reply)
			if match == nil {
				t.Fatalf("done reply = %q, want token", reply)
			}
			botToken = match[0]

			// The new bot can be resolved, and carries the bot flags.
			resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername})
			if err != nil {
				return err
			}
			created := resolvedBot.Users[0].(*tg.User)
			if !created.Bot {
				t.Fatalf("created bot user lacks bot flag: %+v", created)
			}
			if v, ok := created.GetBotInfoVersion(); !ok || v < 1 {
				t.Fatalf("created bot bot_info_version = %d,%v, want >=1", v, ok)
			}
			botUserID = created.ID
			return nil
		}); err != nil {
			t.Fatalf("newbot flow: %v", err)
		}
	}

	// 3) A wrong token must get ACCESS_TOKEN_INVALID.
	{
		client := newClient(&session.StorageMemory{})
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			_, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
				APIID: 1, APIHash: "hash", BotAuthToken: "12345:notarealtokennotarealtokennotareal_",
			})
			if !tgerr.Is(err, "ACCESS_TOKEN_INVALID") {
				t.Fatalf("bad token err = %v, want ACCESS_TOKEN_INVALID", err)
			}
			return nil
		}); err != nil {
			t.Fatalf("bad token flow: %v", err)
		}
	}

	// 4) The bot client logs in with the token; self must be a bot; userFull.bot_info is self-consistent.
	botStorage := &session.StorageMemory{}
	{
		client := newClient(botStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			res, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
				APIID: 1, APIHash: "hash", BotAuthToken: botToken,
			})
			if err != nil {
				return err
			}
			authz, ok := res.(*tg.AuthAuthorization)
			if !ok {
				t.Fatalf("importBotAuthorization = %T, want *tg.AuthAuthorization", res)
			}
			self := authz.User.(*tg.User)
			if self.ID != botUserID || !self.Bot || !self.Self {
				t.Fatalf("bot self = %+v, want self bot id %d", self, botUserID)
			}
			if v, ok := self.GetBotInfoVersion(); !ok || v < 1 {
				t.Fatalf("bot self bot_info_version = %d,%v, want >=1", v, ok)
			}
			// Login takes effect: getUsers(self) and getState work normally.
			got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
			if err != nil {
				return err
			}
			if len(got) != 1 || got[0].(*tg.User).ID != botUserID {
				t.Fatalf("bot getUsers(self) = %+v, want id %d", got, botUserID)
			}
			if _, err := raw.UpdatesGetState(ctx); err != nil {
				return err
			}
			full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{})
			if err != nil {
				return err
			}
			botInfo, ok := full.FullUser.GetBotInfo()
			if !ok {
				t.Fatalf("bot self userFull lacks bot_info")
			}
			if id, ok := botInfo.GetUserID(); !ok || id != botUserID {
				t.Fatalf("bot self bot_info.user_id = %d,%v, want %d", id, ok, botUserID)
			}
			return nil
		}); err != nil {
			t.Fatalf("bot login flow: %v", err)
		}
	}

	// 5) The owner sends a message to the bot.
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername})
			if err != nil {
				return err
			}
			created := resolvedBot.Users[0].(*tg.User)
			if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
				Peer:     &tg.InputPeerUser{UserID: created.ID, AccessHash: created.AccessHash},
				Message:  "hi bot",
				RandomID: 41001,
			}); err != nil {
				return err
			}
			return nil
		}); err != nil {
			t.Fatalf("owner -> bot send: %v", err)
		}
	}

	// 6) The bot reads the message and replies.
	{
		client := newClient(botStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			// access_hash=0 skips validation (consistent with the existing users.getUsers semantics), so the bot obtains the owner's profile this way.
			got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: owner.ID}})
			if err != nil {
				return err
			}
			if len(got) != 1 {
				t.Fatalf("bot getUsers(owner) = %d users, want 1", len(got))
			}
			ownerSeen := got[0].(*tg.User)
			peer := &tg.InputPeerUser{UserID: ownerSeen.ID, AccessHash: ownerSeen.AccessHash}
			history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 5})
			if err != nil {
				return err
			}
			msgs := messagesOf(history)
			if len(msgs) == 0 {
				t.Fatalf("bot history empty, want incoming message")
			}
			top, ok := msgs[0].(*tg.Message)
			if !ok || top.Message != "hi bot" || top.Out {
				t.Fatalf("bot latest = %#v, want incoming 'hi bot'", msgs[0])
			}
			if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
				Peer:     peer,
				Message:  "hello human",
				RandomID: 51001,
			}); err != nil {
				return err
			}

			// P2: bot self-management (bot-only RPC): setBotCommands / setBotMenuButton.
			ok2, err := raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
				Scope:    &tg.BotCommandScopeDefault{},
				LangCode: "",
				Commands: []tg.BotCommand{
					{Command: "start", Description: "start the bot"},
					{Command: "ping", Description: "check liveness"},
				},
			})
			if err != nil || !ok2 {
				t.Fatalf("setBotCommands = %v err=%v, want true", ok2, err)
			}
			cmds, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{
				Scope: &tg.BotCommandScopeDefault{}, LangCode: "",
			})
			if err != nil || len(cmds) != 2 || cmds[0].Command != "start" {
				t.Fatalf("getBotCommands = %+v err=%v, want [start ping]", cmds, err)
			}
			okBtn, err := raw.BotsSetBotMenuButton(ctx, &tg.BotsSetBotMenuButtonRequest{
				UserID: &tg.InputUserEmpty{},
				Button: &tg.BotMenuButton{Text: "Open", URL: "https://example.test/app"},
			})
			if err != nil || !okBtn {
				t.Fatalf("setBotMenuButton = %v err=%v, want true", okBtn, err)
			}
			gotBtn, err := raw.BotsGetBotMenuButton(ctx, &tg.InputUserEmpty{})
			if err != nil {
				return err
			}
			if web, ok := gotBtn.(*tg.BotMenuButton); !ok || web.URL != "https://example.test/app" {
				t.Fatalf("getBotMenuButton = %#v, want webview button", gotBtn)
			}
			return nil
		}); err != nil {
			t.Fatalf("bot read/reply: %v", err)
		}
	}

	// 7) The owner sees the bot's reply.
	{
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername})
			if err != nil {
				return err
			}
			created := resolvedBot.Users[0].(*tg.User)
			history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
				Peer:  &tg.InputPeerUser{UserID: created.ID, AccessHash: created.AccessHash},
				Limit: 5,
			})
			if err != nil {
				return err
			}
			msgs := messagesOf(history)
			if len(msgs) == 0 {
				t.Fatalf("owner history with bot empty")
			}
			top, ok := msgs[0].(*tg.Message)
			if !ok || top.Message != "hello human" || top.Out {
				t.Fatalf("owner latest = %#v, want incoming 'hello human'", msgs[0])
			}

			// P2: the owner's-perspective bot metadata loop.
			// (a) after the bot sets commands + menu button, bot_info_version has bumped (>1).
			if v, ok := created.GetBotInfoVersion(); !ok || v <= 1 {
				t.Fatalf("bot_info_version = %d,%v after metadata changes, want > 1", v, ok)
			}
			// (b) getFullUser: bot_info carries the new commands and the webview menu button; the owner sees bot_can_edit.
			full, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash})
			if err != nil {
				return err
			}
			botInfo, ok := full.FullUser.GetBotInfo()
			if !ok {
				t.Fatal("owner getFullUser(bot) lacks bot_info")
			}
			if cmds, ok := botInfo.GetCommands(); !ok || len(cmds) != 2 || cmds[1].Command != "ping" {
				t.Fatalf("bot_info commands = %+v,%v, want [start ping]", cmds, ok)
			}
			if btn, ok := botInfo.GetMenuButton(); !ok {
				t.Fatal("bot_info lacks menu_button")
			} else if web, isWeb := btn.(*tg.BotMenuButton); !isWeb || web.URL != "https://example.test/app" {
				t.Fatalf("menu_button = %#v, want webview", btn)
			}
			fullUser := full.Users[0].(*tg.User)
			if !fullUser.BotCanEdit {
				t.Fatalf("owner getFullUser(bot) user lacks bot_can_edit: %+v", fullUser)
			}
			// (c) the owner sets setBotInfo (about+description) on the bot's behalf via the bot parameter → getFullUser reflects it.
			setReq := &tg.BotsSetBotInfoRequest{LangCode: ""}
			setReq.SetBot(&tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash})
			setReq.SetAbout("e2e about")
			setReq.SetDescription("e2e description")
			if okSet, err := raw.BotsSetBotInfo(ctx, setReq); err != nil || !okSet {
				t.Fatalf("owner setBotInfo = %v err=%v, want true", okSet, err)
			}
			full2, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash})
			if err != nil {
				return err
			}
			if full2.FullUser.About != "e2e about" {
				t.Fatalf("about after setBotInfo = %q, want 'e2e about'", full2.FullUser.About)
			}
			if bi2, ok := full2.FullUser.GetBotInfo(); !ok {
				t.Fatal("getFullUser after setBotInfo lacks bot_info")
			} else if desc, _ := bi2.GetDescription(); desc != "e2e description" {
				t.Fatalf("description = %q, want 'e2e description'", desc)
			}
			// (d) the owner setting on a bot that is not theirs (BotFather) → BOT_INVALID.
			badReq := &tg.BotsSetBotInfoRequest{LangCode: ""}
			badReq.SetBot(&tg.InputUser{UserID: domain.BotFatherUserID, AccessHash: domain.BotFatherAccessHash})
			badReq.SetAbout("nope")
			if _, err := raw.BotsSetBotInfo(ctx, badReq); !tgerr.Is(err, "BOT_INVALID") {
				t.Fatalf("setBotInfo on BotFather err = %v, want BOT_INVALID", err)
			}
			// (e) a non-bot user calling a bot-only RPC → USER_BOT_REQUIRED.
			if _, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{
				Scope: &tg.BotCommandScopeDefault{}, LangCode: "",
			}); !tgerr.Is(err, "USER_BOT_REQUIRED") {
				t.Fatalf("owner getBotCommands err = %v, want USER_BOT_REQUIRED", err)
			}
			return nil
		}); err != nil {
			t.Fatalf("owner read bot reply: %v", err)
		}
	}

	// 8) The owner uses BotFather /revoke: the old token becomes invalid + all the
	// bot's authorizations are revoked (the logged-in session invalidation loop; the
	// bot reconnecting with the old auth_key will then get 401).
	{
		if auths, err := authzStore.ListByUser(ctx, botUserID); err != nil || len(auths) == 0 {
			t.Fatalf("bot authorizations before revoke = %d err=%v, want >=1", len(auths), err)
		}
		client := newClient(ownerStorage)
		if err := client.Run(ctx, func(ctx context.Context) error {
			raw := tg.NewClient(client)
			resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "BotFather"})
			if err != nil {
				return err
			}
			bf := resolved.Users[0].(*tg.User)
			peer := &tg.InputPeerUser{UserID: bf.ID, AccessHash: bf.AccessHash}
			randomID := int64(61001)
			// Wait until BotFather's reply to this entry appears before sending the
			// next—replies are handled asynchronously, and a bare sleep would reorder
			// them under load (see the sleep flaky item in the P2 review). Poll the top
			// for the incoming reply.
			sendAwait := func(text string) {
				t.Helper()
				before := 0
				if h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 1}); err == nil {
					if m := messagesOf(h); len(m) > 0 {
						if msg, ok := m[0].(*tg.Message); ok {
							before = msg.ID
						}
					}
				}
				randomID++
				if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
					Peer: peer, Message: text, RandomID: randomID,
				}); err != nil {
					t.Fatalf("send %q: %v", text, err)
				}
				deadline := time.Now().Add(10 * time.Second)
				for {
					h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 3})
					if err != nil {
						t.Fatalf("history after %q: %v", text, err)
					}
					msgs := messagesOf(h)
					if len(msgs) > 0 {
						if top, ok := msgs[0].(*tg.Message); ok && !top.Out && top.ID > before {
							return
						}
					}
					if time.Now().After(deadline) {
						t.Fatalf("timed out waiting for BotFather reply to %q", text)
					}
					time.Sleep(40 * time.Millisecond)
				}
			}
			sendAwait("/revoke")
			sendAwait("@" + botUsername)
			// revoke runs inside the BotFather goroutine; poll until the authorization is cleared.
			deadline := time.Now().Add(10 * time.Second)
			for {
				auths, err := authzStore.ListByUser(ctx, botUserID)
				if err != nil {
					return err
				}
				if len(auths) == 0 {
					return nil
				}
				if time.Now().After(deadline) {
					t.Fatalf("bot authorizations not revoked: %d rows remain", len(auths))
				}
				time.Sleep(50 * time.Millisecond)
			}
		}); err != nil {
			t.Fatalf("revoke flow: %v", err)
		}
	}

	cancel()
	if err := <-serveErr; err != nil {
		t.Errorf("serve: %v", err)
	}
}