Button-driven bot management: paginated picker, per-bot API token / revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings toggles (inline/groups/privacy), and delete. Navigation edits the menu message in place via a new editServiceBotMessage helper. Edit Botpic accepts a photo the user sends to @BotFather and sets it as the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto does not accept a bot target so this is the only route.
452 lines
16 KiB
Go
452 lines
16 KiB
Go
package bots
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/memory"
|
||
)
|
||
|
||
func newTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore) {
|
||
t.Helper()
|
||
users := memory.NewUserStore()
|
||
bots := memory.NewBotStore(users)
|
||
dialogs := memory.NewDialogStore()
|
||
messages := memory.NewMessageStore(dialogs)
|
||
return NewService(users, bots, messages), users, bots, messages
|
||
}
|
||
|
||
func newOwner(t *testing.T, users *memory.UserStore, phone string) domain.User {
|
||
t.Helper()
|
||
u, err := users.Create(context.Background(), domain.User{AccessHash: 1, Phone: phone, FirstName: "Owner"})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
// sendToBotFather 同步驱动 responder(绕过 OnPrivateMessage 的 goroutine 派发以
|
||
// 保证单测确定性;异步派发由 mtprotoedge bot e2e 覆盖),返回 BotFather 最新回复文本。
|
||
func botFatherMsg(userID int64, text string) domain.Message {
|
||
return domain.Message{
|
||
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||
Body: text,
|
||
}
|
||
}
|
||
|
||
func sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, text))
|
||
list, err := messages.ListByUser(ctx, owner.ID, 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.Fatalf("no BotFather reply after sending %q", text)
|
||
}
|
||
return latest.Body
|
||
}
|
||
|
||
var tokenRe = regexp.MustCompile(`(\d+):([A-Za-z0-9_-]{35})`)
|
||
|
||
func TestBotFatherNewBotFlow(t *testing.T) {
|
||
svc, users, bots, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+1000")
|
||
ctx := context.Background()
|
||
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newbot") {
|
||
t.Fatalf("/start reply = %q, want help text", reply)
|
||
}
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "choose a name") {
|
||
t.Fatalf("/newbot reply = %q, want name prompt", reply)
|
||
}
|
||
if reply := sendToBotFather(t, svc, messages, owner, "My Test Bot"); !strings.Contains(reply, "username") {
|
||
t.Fatalf("name reply = %q, want username prompt", reply)
|
||
}
|
||
// 非法 username:不以 bot 结尾。
|
||
if reply := sendToBotFather(t, svc, messages, owner, "mytest"); !strings.Contains(reply, "invalid") {
|
||
t.Fatalf("invalid username reply = %q, want invalid notice", reply)
|
||
}
|
||
reply := sendToBotFather(t, svc, messages, owner, "my_test_bot")
|
||
match := tokenRe.FindStringSubmatch(reply)
|
||
if match == nil {
|
||
t.Fatalf("done reply = %q, want token", reply)
|
||
}
|
||
if !strings.Contains(reply, "telesrv.net/my_test_bot") {
|
||
t.Fatalf("done reply = %q, want deep link", reply)
|
||
}
|
||
|
||
created, found, err := users.ByUsername(ctx, "my_test_bot")
|
||
if err != nil || !found {
|
||
t.Fatalf("bot user not found: %v", err)
|
||
}
|
||
if !created.Bot || created.BotInfoVersion < 1 || created.Phone != "" {
|
||
t.Fatalf("bot user = %+v, want bot with bot_info_version>=1 and empty phone", created)
|
||
}
|
||
profile, found, err := bots.GetBot(ctx, created.ID)
|
||
if err != nil || !found {
|
||
t.Fatalf("bot profile not found: %v", err)
|
||
}
|
||
if profile.OwnerUserID != owner.ID {
|
||
t.Fatalf("bot owner = %d, want %d", profile.OwnerUserID, owner.ID)
|
||
}
|
||
if fmt.Sprintf("%d", created.ID) != match[1] || profile.TokenSecret != match[2] {
|
||
t.Fatalf("token %q does not match stored bot %d/%q", match[0], created.ID, profile.TokenSecret)
|
||
}
|
||
// 状态机已复位:普通文本回兜底提示。
|
||
if reply := sendToBotFather(t, svc, messages, owner, "hello"); !strings.Contains(reply, "/help") {
|
||
t.Fatalf("post-done reply = %q, want fallback", reply)
|
||
}
|
||
}
|
||
|
||
func TestBotProfileCacheCachesPositiveAndNegativeProfiles(t *testing.T) {
|
||
users := memory.NewUserStore()
|
||
botsStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
|
||
dialogs := memory.NewDialogStore()
|
||
messages := memory.NewMessageStore(dialogs)
|
||
svc := NewService(users, botsStore, messages)
|
||
owner := newOwner(t, users, "+1090")
|
||
ctx := context.Background()
|
||
|
||
bot, _, err := svc.CreateBot(ctx, owner.ID, "Cache Bot", "cache_test_bot")
|
||
if err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
|
||
botsStore.reset()
|
||
if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found {
|
||
t.Fatalf("negative BotInfo = found %v err %v, want false,nil", found, err)
|
||
}
|
||
if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found {
|
||
t.Fatalf("second negative BotInfo = found %v err %v, want false,nil", found, err)
|
||
}
|
||
if botsStore.getBotCalls != 1 {
|
||
t.Fatalf("negative GetBot calls = %d, want 1", botsStore.getBotCalls)
|
||
}
|
||
|
||
botsStore.reset()
|
||
if profile, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found || profile.BotUserID != bot.ID {
|
||
t.Fatalf("cached positive BotInfo = profile %+v found %v err %v", profile, found, err)
|
||
}
|
||
if _, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found {
|
||
t.Fatalf("second positive BotInfo = found %v err %v, want true,nil", found, err)
|
||
}
|
||
if botsStore.getBotCalls != 0 {
|
||
t.Fatalf("positive GetBot calls after create prewarm = %d, want 0", botsStore.getBotCalls)
|
||
}
|
||
|
||
botsStore.reset()
|
||
profiles, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243, 424243})
|
||
if err != nil {
|
||
t.Fatalf("batch BotInfos: %v", err)
|
||
}
|
||
if len(profiles) != 1 || profiles[bot.ID].BotUserID != bot.ID {
|
||
t.Fatalf("batch profiles = %+v, want only bot", profiles)
|
||
}
|
||
if botsStore.getBotsCalls != 1 {
|
||
t.Fatalf("batch GetBots calls = %d, want 1 for new miss", botsStore.getBotsCalls)
|
||
}
|
||
if _, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243}); err != nil {
|
||
t.Fatalf("second batch BotInfos: %v", err)
|
||
}
|
||
if botsStore.getBotsCalls != 1 {
|
||
t.Fatalf("second batch GetBots calls = %d, want still 1", botsStore.getBotsCalls)
|
||
}
|
||
|
||
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "start", Description: "begin"}}); err != nil {
|
||
t.Fatalf("set commands: %v", err)
|
||
}
|
||
botsStore.reset()
|
||
profile, found, err := svc.BotInfo(ctx, bot.ID)
|
||
if err != nil || !found {
|
||
t.Fatalf("BotInfo after invalidation = found %v err %v", found, err)
|
||
}
|
||
if len(profile.Commands) != 1 || profile.Commands[0].Command != "start" {
|
||
t.Fatalf("commands after invalidation = %+v, want [start]", profile.Commands)
|
||
}
|
||
if botsStore.getBotCalls != 1 {
|
||
t.Fatalf("GetBot calls after invalidation = %d, want 1", botsStore.getBotCalls)
|
||
}
|
||
}
|
||
|
||
type countingBotStore struct {
|
||
*memory.BotStore
|
||
getBotCalls int
|
||
getBotsCalls int
|
||
deleteCalls int
|
||
}
|
||
|
||
func (s *countingBotStore) reset() {
|
||
s.getBotCalls = 0
|
||
s.getBotsCalls = 0
|
||
}
|
||
|
||
func (s *countingBotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||
s.getBotCalls++
|
||
return s.BotStore.GetBot(ctx, botUserID)
|
||
}
|
||
|
||
func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
|
||
s.getBotsCalls++
|
||
return s.BotStore.GetBots(ctx, botUserIDs)
|
||
}
|
||
|
||
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
|
||
s.deleteCalls++
|
||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||
}
|
||
|
||
func TestBotFatherCancelAndUnknown(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+1001")
|
||
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "No active command") {
|
||
t.Fatalf("idle /cancel reply = %q", reply)
|
||
}
|
||
sendToBotFather(t, svc, messages, owner, "/newbot")
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "cancelled") {
|
||
t.Fatalf("active /cancel reply = %q", reply)
|
||
}
|
||
// 取消后名字输入不再被当作 newbot 步骤。
|
||
if reply := sendToBotFather(t, svc, messages, owner, "Some Name"); !strings.Contains(reply, "/help") {
|
||
t.Fatalf("post-cancel reply = %q, want fallback", reply)
|
||
}
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/definitelynotacommand"); !strings.Contains(reply, "Unrecognized") {
|
||
t.Fatalf("unknown command reply = %q", reply)
|
||
}
|
||
}
|
||
|
||
func TestBotFatherUsernameTaken(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+1002")
|
||
|
||
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "First", "taken_bot"); err != nil {
|
||
t.Fatalf("seed first bot: %v", err)
|
||
}
|
||
sendToBotFather(t, svc, messages, owner, "/newbot")
|
||
sendToBotFather(t, svc, messages, owner, "Second")
|
||
if reply := sendToBotFather(t, svc, messages, owner, "taken_bot"); !strings.Contains(reply, "already taken") {
|
||
t.Fatalf("taken username reply = %q", reply)
|
||
}
|
||
// 状态保留:可继续尝试新 username。
|
||
if reply := sendToBotFather(t, svc, messages, owner, "second_bot"); !strings.Contains(reply, "telesrv.net/second_bot") {
|
||
t.Fatalf("retry username reply = %q", reply)
|
||
}
|
||
}
|
||
|
||
type usernameResolverStub struct {
|
||
taken string
|
||
}
|
||
|
||
func (s usernameResolverStub) ResolvePublicChannelUsername(_ context.Context, _ int64, username string) (domain.Channel, bool, error) {
|
||
return domain.Channel{}, strings.EqualFold(username, s.taken), nil
|
||
}
|
||
|
||
func TestCheckUsernameRejectsUserAndPublicChannelCollision(t *testing.T) {
|
||
users := memory.NewUserStore()
|
||
botsStore := memory.NewBotStore(users)
|
||
dialogs := memory.NewDialogStore()
|
||
messages := memory.NewMessageStore(dialogs)
|
||
svc := NewService(users, botsStore, messages, WithPublicChannelUsernameResolver(usernameResolverStub{taken: "channel_bot"}))
|
||
owner := newOwner(t, users, "+1012")
|
||
ctx := context.Background()
|
||
|
||
if ok, err := svc.CheckUsername(ctx, owner.ID, "fresh_bot"); err != nil || !ok {
|
||
t.Fatalf("fresh username = %v,%v, want true,nil", ok, err)
|
||
}
|
||
if _, _, err := svc.CreateBot(ctx, owner.ID, "Taken", "user_taken_bot"); err != nil {
|
||
t.Fatalf("seed bot: %v", err)
|
||
}
|
||
if ok, err := svc.CheckUsername(ctx, owner.ID, "user_taken_bot"); err != nil || ok {
|
||
t.Fatalf("user collision = %v,%v, want false,nil", ok, err)
|
||
}
|
||
if ok, err := svc.CheckUsername(ctx, owner.ID, "channel_bot"); err != nil || ok {
|
||
t.Fatalf("channel collision = %v,%v, want false,nil", ok, err)
|
||
}
|
||
if _, _, err := svc.CreateBot(ctx, owner.ID, "Channel Collision", "channel_bot"); err != domain.ErrUsernameOccupied {
|
||
t.Fatalf("create channel collision err = %v, want ErrUsernameOccupied", err)
|
||
}
|
||
if _, err := svc.CheckUsername(ctx, owner.ID, "notvalid"); err != domain.ErrBotUsernameInvalid {
|
||
t.Fatalf("invalid username err = %v, want ErrBotUsernameInvalid", err)
|
||
}
|
||
}
|
||
|
||
func TestBotFatherTokenAndRevoke(t *testing.T) {
|
||
svc, users, bots, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+1003")
|
||
ctx := context.Background()
|
||
|
||
created, token, err := svc.CreateBot(ctx, owner.ID, "Token Bot", "tok_test_bot")
|
||
if err != nil {
|
||
t.Fatalf("create bot: %v", err)
|
||
}
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/token")
|
||
if reply := sendToBotFather(t, svc, messages, owner, "@tok_test_bot"); !strings.Contains(reply, token) {
|
||
t.Fatalf("/token reply = %q, want current token %q", reply, token)
|
||
}
|
||
|
||
sendToBotFather(t, svc, messages, owner, "/revoke")
|
||
reply := sendToBotFather(t, svc, messages, owner, "tok_test_bot")
|
||
match := tokenRe.FindStringSubmatch(reply)
|
||
if match == nil {
|
||
t.Fatalf("/revoke reply = %q, want new token", reply)
|
||
}
|
||
newToken := match[0]
|
||
if newToken == token {
|
||
t.Fatalf("revoke kept old token %q", token)
|
||
}
|
||
profile, _, err := bots.GetBot(ctx, created.ID)
|
||
if err != nil {
|
||
t.Fatalf("get bot: %v", err)
|
||
}
|
||
if domain.FormatBotToken(created.ID, profile.TokenSecret) != newToken {
|
||
t.Fatalf("stored secret %q does not match revoked token %q", profile.TokenSecret, newToken)
|
||
}
|
||
|
||
// 选择不属于自己的 bot。
|
||
sendToBotFather(t, svc, messages, owner, "/token")
|
||
if reply := sendToBotFather(t, svc, messages, owner, "@nosuch_bot"); !strings.Contains(reply, "don't see that bot") {
|
||
t.Fatalf("unknown choose reply = %q", reply)
|
||
}
|
||
}
|
||
|
||
func TestBotFatherMyBotsAndLimit(t *testing.T) {
|
||
svc, users, _, messages := newTestService(t)
|
||
owner := newOwner(t, users, "+1004")
|
||
ctx := context.Background()
|
||
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "don't have any bots") {
|
||
t.Fatalf("empty /mybots reply = %q", reply)
|
||
}
|
||
for i := 0; i < domain.MaxBotsPerOwner; i++ {
|
||
if _, _, err := svc.CreateBot(ctx, owner.ID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("limit%d_bot", i)); err != nil {
|
||
t.Fatalf("create bot %d: %v", i, err)
|
||
}
|
||
}
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "Choose a bot") {
|
||
t.Fatalf("/mybots reply = %q, want bot picker", reply)
|
||
}
|
||
if _, _, err := svc.CreateBot(ctx, owner.ID, "One Too Many", "toomany_bot"); err != domain.ErrBotsTooMany {
|
||
t.Fatalf("create over limit err = %v, want ErrBotsTooMany", err)
|
||
}
|
||
if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "limit") {
|
||
t.Fatalf("over-limit /newbot reply = %q", reply)
|
||
}
|
||
}
|
||
|
||
func TestParseBotCommand(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
cmd string
|
||
ok bool
|
||
}{
|
||
{"/newbot", "newbot", true},
|
||
{"/NewBot@BotFather", "newbot", true},
|
||
{"/token extra args", "token", true},
|
||
{"plain text", "", false},
|
||
{"/", "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
cmd, ok := parseBotCommand(tc.in)
|
||
if cmd != tc.cmd || ok != tc.ok {
|
||
t.Errorf("parseBotCommand(%q) = %q,%v want %q,%v", tc.in, cmd, ok, tc.cmd, tc.ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
type stubBlocker struct {
|
||
blocked bool
|
||
gotUser int64
|
||
gotPeer int64
|
||
}
|
||
|
||
func (s *stubBlocker) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) {
|
||
s.gotUser, s.gotPeer = userID, blockedUserID
|
||
return s.blocked, nil
|
||
}
|
||
|
||
func TestBotFatherReplyRespectsBlock(t *testing.T) {
|
||
users := memory.NewUserStore()
|
||
bots := memory.NewBotStore(users)
|
||
dialogs := memory.NewDialogStore()
|
||
messages := memory.NewMessageStore(dialogs)
|
||
blocker := &stubBlocker{blocked: true}
|
||
svc := NewService(users, bots, messages, WithBlockChecker(blocker))
|
||
owner := newOwner(t, users, "+1099")
|
||
ctx := context.Background()
|
||
|
||
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, "/help"))
|
||
|
||
// IsBlocked 参数语义:owner(userID) 是否 block 了 BotFather(blockedUserID)。
|
||
if blocker.gotUser != owner.ID || blocker.gotPeer != domain.BotFatherUserID {
|
||
t.Fatalf("IsBlocked called with (user=%d, peer=%d), want (%d, %d)", blocker.gotUser, blocker.gotPeer, owner.ID, domain.BotFatherUserID)
|
||
}
|
||
// 被 block:回复不投递到 owner 收件箱。
|
||
list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list history: %v", err)
|
||
}
|
||
for _, msg := range list.Messages {
|
||
if msg.From.ID == domain.BotFatherUserID {
|
||
t.Fatalf("blocked owner received BotFather reply: %q", msg.Body)
|
||
}
|
||
}
|
||
|
||
// 未 block:回复正常投递。
|
||
blocker.blocked = false
|
||
other := newOwner(t, users, "+1098")
|
||
svc.respondAsBotFather(other.ID, botFatherMsg(other.ID, "/help"))
|
||
otherList, err := messages.ListByUser(ctx, other.ID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list other history: %v", err)
|
||
}
|
||
delivered := false
|
||
for _, msg := range otherList.Messages {
|
||
if msg.From.ID == domain.BotFatherUserID {
|
||
delivered = true
|
||
}
|
||
}
|
||
if !delivered {
|
||
t.Fatal("unblocked user did not receive BotFather reply")
|
||
}
|
||
}
|
||
|
||
func TestValidBotUsername(t *testing.T) {
|
||
valid := []string{"my_bot", "TetrisBot", "a1234bot", "x_bot_BOT"}
|
||
invalid := []string{"bot", "abot", "1abcbot", "_abcbot", "has space bot", "endsinbo", strings.Repeat("a", 30) + "bot" + "x"}
|
||
for _, u := range valid {
|
||
if !domain.ValidBotUsername(u) {
|
||
t.Errorf("ValidBotUsername(%q) = false, want true", u)
|
||
}
|
||
}
|
||
for _, u := range invalid {
|
||
if domain.ValidBotUsername(u) {
|
||
t.Errorf("ValidBotUsername(%q) = true, want false", u)
|
||
}
|
||
}
|
||
}
|