chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,835 @@
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/gotd/td/clock"
"github.com/gotd/td/exchange"
"github.com/gotd/td/session"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"github.com/gotd/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(), 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)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: 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(), 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)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: 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)
}
}