837 lines
30 KiB
Go
837 lines
30 KiB
Go
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 是 bot 主链路端到端验证:
|
||
// TestBotManagementRPCFlow 验证 P2 bots.* 管理 RPC 端到端:
|
||
// bot 自己调 setBotCommands/setBotInfo/setBotMenuButton 后 getFullUser 反映新值且
|
||
// bot_info_version 单调递增;owner 视角 getFullUser(bot) 带 bot_can_edit 且 owner 可
|
||
// 经 bot:InputUser 代改 bot name。
|
||
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 注册。
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 直接经 service 建 bot(绕过 BotFather 对话,聚焦管理 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)
|
||
}
|
||
|
||
// bot 登录并调管理 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
|
||
|
||
// 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 bot self(不带 bot 参数);Description 是 flag.1,须 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)
|
||
}
|
||
|
||
// bot self 不应带 bot_can_edit(owner 视角才有)。
|
||
_, 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 视角:getFullUser(bot) 带 bot_can_edit + commands;owner 代改 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))
|
||
}
|
||
}
|
||
// owner 代改 bot name(带 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)
|
||
}
|
||
}
|
||
|
||
// 用户注册 → resolveUsername(BotFather) → /newbot 对话拿 token →
|
||
// 外部客户端 auth.importBotAuthorization 登录为 bot →
|
||
// 用户与 bot 互发消息 → 错误 token 拿 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 注册。
|
||
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) owner 与 BotFather 对话创建 bot,拿 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 必须存在且 user_id 匹配(TDesktop P0 兼容点)。
|
||
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 回复异步到达(go routine),轮询历史顶部直到出现新的 incoming 回复。
|
||
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]
|
||
|
||
// 新 bot 可被 resolve,且带 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) 错误 token 必须拿 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) bot 客户端凭 token 登录;self 必须是 bot;userFull.bot_info 自洽。
|
||
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)
|
||
}
|
||
// 登录态生效:getUsers(self) 与 getState 正常。
|
||
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) owner 给 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) bot 读到消息并回复。
|
||
{
|
||
client := newClient(botStorage)
|
||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||
raw := tg.NewClient(client)
|
||
// access_hash=0 跳过校验(与现有 users.getUsers 语义一致),bot 据此拿 owner 资料。
|
||
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 自管理(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) owner 看到 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)
|
||
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: owner 视角的 bot 元数据闭环。
|
||
// (a) bot 设置命令+菜单按钮后 bot_info_version 已 bump(>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 带新命令与 webview 菜单按钮;owner 看到 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) owner 经 bot 参数代设 setBotInfo(about+description)→ getFullUser 反映。
|
||
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) owner 对非自己的 bot(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) 非 bot 用户调 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) owner 走 BotFather /revoke:旧 token 失效 + bot 全部 authorization 被撤销
|
||
//(已登录 session 失效闭环;bot 凭旧 auth_key 重连后将得 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)
|
||
// 等到 BotFather 对该条的回复出现再发下一条——回复异步处理,裸 sleep 在
|
||
// 负载下会乱序(见 P2 审查的 sleep flaky 项)。轮询顶部 incoming 回复。
|
||
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 在 BotFather goroutine 内执行;轮询 authorization 清空。
|
||
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)
|
||
}
|
||
}
|