chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
686
internal/app/bots/botfather.go
Normal file
686
internal/app/bots/botfather.go
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotFather 对话状态机:用户发给 BotFather 的每条私聊消息经 app/messages 的
|
||||
// responder hook 进入 OnPrivateMessage;用户消息已先行入库,这里只负责生成并
|
||||
// 写入 BotFather 的回复(完整 SendPrivateText 链路:双盒+事件+outbox 推送)。
|
||||
|
||||
const (
|
||||
botFatherCmdNewBot = "newbot"
|
||||
botFatherCmdToken = "token"
|
||||
botFatherCmdRevoke = "revoke"
|
||||
botFatherCmdSetName = "setname"
|
||||
botFatherCmdSetDescription = "setdescription"
|
||||
botFatherCmdSetAbout = "setabouttext"
|
||||
botFatherCmdSetCommands = "setcommands"
|
||||
botFatherCmdSetInline = "setinline"
|
||||
botFatherCmdSetInlineGeo = "setinlinegeo"
|
||||
botFatherCmdSetInlineFB = "setinlinefeedback"
|
||||
botFatherCmdSetJoinGroups = "setjoingroups"
|
||||
botFatherCmdSetPrivacy = "setprivacy"
|
||||
|
||||
botFatherStepName = "name"
|
||||
botFatherStepUsername = "username"
|
||||
botFatherStepChoose = "choose"
|
||||
botFatherStepValue = "value"
|
||||
|
||||
botFatherDraftBotID = "bot_id"
|
||||
botFatherDraftBotUsername = "bot_username"
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage Telegram bots.
|
||||
|
||||
You can control me by sending these commands:
|
||||
|
||||
/newbot - create a new bot
|
||||
/mybots - list your bots
|
||||
/token - show a bot's token
|
||||
/revoke - revoke a bot's token
|
||||
/setname - change a bot's name
|
||||
/setdescription - change a bot's description
|
||||
/setabouttext - change a bot's about info
|
||||
/setcommands - change a bot's command list
|
||||
/setinline - toggle inline mode
|
||||
/setinlinegeo - toggle inline location requests
|
||||
/setinlinefeedback - change inline feedback settings
|
||||
/setjoingroups - toggle whether a bot can join groups
|
||||
/setprivacy - toggle a bot's group privacy mode
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
// botReply 是 BotFather 的一条回复。
|
||||
type botReply struct {
|
||||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
}
|
||||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
||||
return s != nil && botUserID == domain.BotFatherUserID
|
||||
}
|
||||
|
||||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
|
||||
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
|
||||
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
|
||||
if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID {
|
||||
return
|
||||
}
|
||||
userID := msg.From.ID
|
||||
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
|
||||
return
|
||||
}
|
||||
go s.respondAsBotFather(userID, msg.Body)
|
||||
}
|
||||
|
||||
// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。
|
||||
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
|
||||
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
|
||||
func (s *Service) respondAsBotFather(userID int64, body string) {
|
||||
mu := &s.replyLocks[uint64(userID)%replyLockStripes]
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
reply := s.handleBotFather(ctx, userID, body)
|
||||
if reply.Text == "" {
|
||||
return
|
||||
}
|
||||
blocked := false
|
||||
if s.blocker != nil {
|
||||
if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil {
|
||||
s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err))
|
||||
} else {
|
||||
blocked = b
|
||||
}
|
||||
}
|
||||
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: domain.BotFatherUserID,
|
||||
RecipientUserID: userID,
|
||||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
Entities: reply.Entities,
|
||||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: blocked,
|
||||
}); err != nil {
|
||||
s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
|
||||
// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用
|
||||
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
||||
func (s *Service) botReplyRandomID() int64 {
|
||||
if v, err := randomInt64(); err == nil && v != 0 {
|
||||
return v
|
||||
}
|
||||
v := s.now().UnixNano() + s.replySeq.Add(1)
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// botFatherGlobalCommands 是任何状态下都优先按命令处理的全局命令。其余以 "/"
|
||||
// 开头的文本(如 /empty、或粘贴的 "/start - Begin" 命令列表首行)在收值步骤里
|
||||
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
|
||||
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
|
||||
var botFatherGlobalCommands = map[string]bool{
|
||||
"start": true, "help": true, "cancel": true,
|
||||
botFatherCmdNewBot: true, "mybots": true,
|
||||
botFatherCmdToken: true, botFatherCmdRevoke: true,
|
||||
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
|
||||
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
|
||||
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
text = strings.TrimSpace(text)
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
// 命令拦截:仅当不在收值步骤、或文本是已知全局命令时,才走命令分发。收值步骤
|
||||
// 下的非全局 "/..." 文本(/empty、命令列表首行)必须当原始值透传给状态机。
|
||||
if cmd, ok := parseBotCommand(text); ok {
|
||||
inValueStep := found && state.Step == botFatherStepValue
|
||||
if !inValueStep || botFatherGlobalCommands[cmd] {
|
||||
return s.handleBotFatherCommand(ctx, userID, cmd)
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
// 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示,
|
||||
// 无状态保持沉默(避免对任意非文本消息刷屏)。
|
||||
if !found {
|
||||
return botReply{}
|
||||
}
|
||||
return s.stepPrompt(state)
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: "I can only help you create and manage bots. Send /help for a list of commands."}
|
||||
}
|
||||
switch {
|
||||
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
|
||||
return s.handleNewBotName(ctx, state, text)
|
||||
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
|
||||
return s.handleNewBotUsername(ctx, state, text)
|
||||
case state.Step == botFatherStepChoose:
|
||||
return s.handleChooseBot(ctx, state, text)
|
||||
case state.Step == botFatherStepValue:
|
||||
return s.handleSetValue(ctx, state, text)
|
||||
default:
|
||||
// 不可达的脏状态:清掉重来,避免用户被卡死。
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: "Something went wrong, I forgot what we were doing. Send /help for a list of commands."}
|
||||
}
|
||||
}
|
||||
|
||||
// pickerCommands 是「先选 bot」的命令集(choose step 后按命令分流)。
|
||||
var pickerPrompts = map[string]string{
|
||||
botFatherCmdToken: "Choose a bot to generate a token for. Send the bot's username:",
|
||||
botFatherCmdRevoke: "Choose a bot to revoke the token of. Send the bot's username:",
|
||||
botFatherCmdSetName: "Choose a bot to change the name of. Send the bot's username:",
|
||||
botFatherCmdSetDescription: "Choose a bot to change the description of. Send the bot's username:",
|
||||
botFatherCmdSetAbout: "Choose a bot to change the about info of. Send the bot's username:",
|
||||
botFatherCmdSetCommands: "Choose a bot to change the command list of. Send the bot's username:",
|
||||
botFatherCmdSetInline: "Choose a bot to change inline mode for. Send the bot's username:",
|
||||
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
|
||||
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
|
||||
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
|
||||
}
|
||||
|
||||
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
|
||||
func (s *Service) startBotPicker(ctx context.Context, userID int64, cmd string) botReply {
|
||||
usernames, err := s.ownedBotUsernames(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if len(usernames) == 0 {
|
||||
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
|
||||
}
|
||||
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: cmd,
|
||||
Step: botFatherStepChoose,
|
||||
}); err != nil {
|
||||
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: pickerPrompts[cmd] + "\n\n@" + strings.Join(usernames, "\n@")}
|
||||
}
|
||||
|
||||
// stepPrompt 返回当前对话步骤的引导文案(空输入兜底用)。
|
||||
func (s *Service) stepPrompt(state domain.BotChatState) botReply {
|
||||
switch {
|
||||
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
|
||||
return botReply{Text: "Please choose a name for your bot, or /cancel."}
|
||||
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
|
||||
return botReply{Text: "Please send a username for your bot. It must end in `bot`. Or /cancel."}
|
||||
case state.Step == botFatherStepChoose:
|
||||
return botReply{Text: "Please send the username of one of your bots, or /cancel."}
|
||||
case state.Step == botFatherStepValue:
|
||||
return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])}
|
||||
default:
|
||||
return botReply{Text: "Send /help for a list of commands."}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
|
||||
switch cmd {
|
||||
case "start", "help":
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: botFatherHelpText}
|
||||
case "cancel":
|
||||
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: "No active command to cancel. I wasn't doing anything anyway."}
|
||||
}
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
|
||||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
|
||||
case botFatherCmdNewBot:
|
||||
count, err := s.bots.CountBotsByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: count bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if count >= domain.MaxBotsPerOwner {
|
||||
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
|
||||
}
|
||||
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: botFatherCmdNewBot,
|
||||
Step: botFatherStepName,
|
||||
}); err != nil {
|
||||
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."}
|
||||
case "mybots":
|
||||
usernames, err := s.ownedBotUsernames(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if len(usernames) == 0 {
|
||||
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
|
||||
}
|
||||
return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")}
|
||||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
return s.startBotPicker(ctx, userID, cmd)
|
||||
case botFatherCmdSetInlineFB:
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: "Inline feedback settings are not supported yet. Use /setinline to enable or disable inline mode."}
|
||||
default:
|
||||
return botReply{Text: "Unrecognized command. Say what? Send /help for a list of commands."}
|
||||
}
|
||||
}
|
||||
|
||||
// valuePrompts 是选中 bot 后、value step 的收值提示(按命令)。
|
||||
func valuePrompt(cmd, username string) string {
|
||||
switch cmd {
|
||||
case botFatherCmdSetName:
|
||||
return fmt.Sprintf("OK. Send me the new name for @%s.", username)
|
||||
case botFatherCmdSetDescription:
|
||||
return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username)
|
||||
case botFatherCmdSetAbout:
|
||||
return fmt.Sprintf("OK. Send me the new about text for @%s. People will see this text on the bot's profile page and it will be sent together with a link to your bot when they share it with someone.", username)
|
||||
case botFatherCmdSetCommands:
|
||||
return fmt.Sprintf("OK. Send me a list of commands for @%s. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to clear the list.", username)
|
||||
case botFatherCmdSetInline:
|
||||
return fmt.Sprintf("This will enable inline queries for @%s. Send me the placeholder text people will see after typing the bot username, or send /empty to disable inline mode.", username)
|
||||
case botFatherCmdSetInlineGeo:
|
||||
return fmt.Sprintf("Send 'enable' to allow @%s to receive location in inline queries, or 'disable' to turn it off.", username)
|
||||
case botFatherCmdSetJoinGroups:
|
||||
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
|
||||
case botFatherCmdSetPrivacy:
|
||||
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
|
||||
default:
|
||||
return "Send the new value, or /cancel."
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleNewBotName(ctx context.Context, state domain.BotChatState, name string) botReply {
|
||||
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
|
||||
return botReply{Text: fmt.Sprintf("Sorry, the bot name must be 1-%d characters long. Please choose a different name.", domain.MaxBotNameLength)}
|
||||
}
|
||||
state.Step = botFatherStepUsername
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft["name"] = name
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: "Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot."}
|
||||
}
|
||||
|
||||
func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChatState, username string) botReply {
|
||||
u, token, err := s.CreateBot(ctx, state.UserID, state.Draft["name"], username)
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrBotUsernameInvalid):
|
||||
return botReply{Text: "Sorry, this username is invalid. A bot username must be 5-32 characters long, start with a letter, contain only Latin letters, digits and underscores, and end in 'bot' (e.g. tetris_bot)."}
|
||||
case errors.Is(err, domain.ErrUsernameOccupied):
|
||||
return botReply{Text: "Sorry, this username is already taken. Please try something different."}
|
||||
case errors.Is(err, domain.ErrBotsTooMany):
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID)
|
||||
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
|
||||
case errors.Is(err, domain.ErrBotNameInvalid):
|
||||
// name 步已校验,这里只可能是脏状态;重新走 name 步。
|
||||
state.Step = botFatherStepName
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: "Please choose a name for your bot first."}
|
||||
case err != nil:
|
||||
s.log.Error("botfather: create bot", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
|
||||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
}
|
||||
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at telesrv.net/%s.\n\nUse this token to access the HTTP API:\n", u.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
}
|
||||
|
||||
func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState, text string) botReply {
|
||||
username := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(text, "@")))
|
||||
profiles, err := s.ownedBots(ctx, state.UserID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
var chosen *domain.User
|
||||
for i := range profiles {
|
||||
if strings.ToLower(profiles[i].user.Username) == username {
|
||||
chosen = &profiles[i].user
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return botReply{Text: "I don't see that bot among yours. Send the username of one of your bots, or /cancel."}
|
||||
}
|
||||
switch state.Command {
|
||||
case botFatherCmdToken:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
profile, found, err := s.bots.GetBot(ctx, chosen.ID)
|
||||
if err != nil || !found || profile.TokenSecret == "" {
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get bot", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
}
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("You can use this token to access the HTTP API for @%s:\n", chosen.Username)
|
||||
return tokenReply(head, domain.FormatBotToken(chosen.ID, profile.TokenSecret), "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdRevoke:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
token, err := s.RevokeBotToken(ctx, state.UserID, chosen.ID)
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
|
||||
// token 已换(旧 token 不可再登录),但未能终止已建立的 session——
|
||||
// 诚实告知用户重试,不谎称已止血。
|
||||
head := fmt.Sprintf("Token for @%s has been changed, so the old token can no longer log in. But I couldn't terminate sessions that are already logged in — please run /revoke again to make sure they're cut off. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case err != nil:
|
||||
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
// 选中 bot 后进入收值 step,把目标 bot 暂存进 Draft。
|
||||
state.Step = botFatherStepValue
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
|
||||
state.Draft[botFatherDraftBotUsername] = chosen.Username
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: valuePrompt(state.Command, chosen.Username)}
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。
|
||||
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply {
|
||||
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
|
||||
username := state.Draft[botFatherDraftBotUsername]
|
||||
if botID == 0 {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /help."}
|
||||
}
|
||||
// 防御性复核 owner(状态是服务端存的,正常已是 owned bot)。
|
||||
if owns, err := s.OwnsBot(ctx, state.UserID, botID); err != nil {
|
||||
s.log.Error("botfather: owns bot", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
} else if !owns {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "That bot is no longer available."}
|
||||
}
|
||||
|
||||
var (
|
||||
reply botReply
|
||||
err error
|
||||
)
|
||||
switch state.Command {
|
||||
case botFatherCmdSetName:
|
||||
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetName: true, Name: text})
|
||||
reply = okReply(err, fmt.Sprintf("Success! Name updated for @%s.", username), "Sorry, that name is invalid. Please try a different one.")
|
||||
case botFatherCmdSetDescription:
|
||||
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetDescription: true, Description: text})
|
||||
reply = okReply(err, "Success! Description updated.", "Sorry, that description is too long.")
|
||||
case botFatherCmdSetAbout:
|
||||
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetAbout: true, About: text})
|
||||
reply = okReply(err, "Success! About section updated.", "Sorry, that about text is too long.")
|
||||
case botFatherCmdSetCommands:
|
||||
reply, err = s.applySetCommands(ctx, botID, text)
|
||||
case botFatherCmdSetInline:
|
||||
reply, err = s.applySetInline(ctx, botID, text)
|
||||
case botFatherCmdSetInlineGeo:
|
||||
reply, err = s.applySetInlineGeo(ctx, botID, text)
|
||||
case botFatherCmdSetJoinGroups:
|
||||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
}
|
||||
if err != nil {
|
||||
// 校验类错误已转成提示文案;非校验错误内部已记日志。保留 state 让用户重试。
|
||||
if reply.Text == "" {
|
||||
return internalReply()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
s.clearState(ctx, state.UserID)
|
||||
return reply
|
||||
}
|
||||
|
||||
// applySetCommands 解析多行 "command - Description"(/empty 清空)并写入。
|
||||
func (s *Service) applySetCommands(ctx context.Context, botID int64, text string) (botReply, error) {
|
||||
var commands []domain.BotCommand
|
||||
if strings.TrimSpace(text) != "/empty" {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cmd, desc, ok := strings.Cut(line, "-")
|
||||
if !ok {
|
||||
return botReply{Text: "Invalid format. Each line must be: command - Description. Try again or /cancel."}, domain.ErrBotCommandInvalid
|
||||
}
|
||||
commands = append(commands, domain.BotCommand{
|
||||
Command: strings.TrimSpace(cmd),
|
||||
Description: strings.TrimSpace(desc),
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := s.SetBotCommands(ctx, botID, commands); err != nil {
|
||||
return botReply{Text: "Invalid command list. Each command must be 1-32 chars (letters, digits, underscores) with a non-empty description. Try again or /cancel."}, err
|
||||
}
|
||||
return botReply{Text: "Success! Command list updated. /help"}, nil
|
||||
}
|
||||
|
||||
// applySetInline 写入 inline placeholder;/empty 清空并关闭 inline mode。
|
||||
func (s *Service) applySetInline(ctx context.Context, botID int64, text string) (botReply, error) {
|
||||
placeholder := strings.TrimSpace(text)
|
||||
if placeholder == "/empty" {
|
||||
placeholder = ""
|
||||
}
|
||||
if _, err := s.SetInlinePlaceholder(ctx, botID, placeholder); err != nil {
|
||||
return botReply{Text: fmt.Sprintf("Sorry, inline placeholder must be at most %d characters. Try again or /cancel.", domain.MaxBotInlinePlaceholderLen)}, err
|
||||
}
|
||||
if placeholder == "" {
|
||||
return botReply{Text: "Success! Inline mode disabled. /help"}, nil
|
||||
}
|
||||
return botReply{Text: "Success! Inline settings updated. /help"}, nil
|
||||
}
|
||||
|
||||
func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text string) (botReply, error) {
|
||||
var enabled bool
|
||||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||
case "enable", "on", "yes":
|
||||
enabled = true
|
||||
case "disable", "off", "no":
|
||||
enabled = false
|
||||
default:
|
||||
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
|
||||
}
|
||||
if _, err := s.SetInlineGeo(ctx, botID, enabled); err != nil {
|
||||
s.log.Error("botfather: set inline geo", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return botReply{}, err
|
||||
}
|
||||
state := "disabled"
|
||||
if enabled {
|
||||
state = "enabled"
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||
case "enable", "on", "yes":
|
||||
on = true
|
||||
case "disable", "off", "no":
|
||||
on = false
|
||||
default:
|
||||
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
|
||||
}
|
||||
var err error
|
||||
if join {
|
||||
_, err = s.SetJoinGroups(ctx, botID, on)
|
||||
} else {
|
||||
_, err = s.SetPrivacy(ctx, botID, on)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: apply toggle", zap.Int64("bot_user_id", botID), zap.Bool("join", join), zap.Error(err))
|
||||
return botReply{}, err
|
||||
}
|
||||
what := "Group privacy"
|
||||
if join {
|
||||
what = "Group joining"
|
||||
}
|
||||
state := "disabled"
|
||||
if on {
|
||||
state = "enabled"
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! %s is now %s.", what, state)}, nil
|
||||
}
|
||||
|
||||
// okReply 把 service 调用结果转成提示:err==nil 回成功,否则回校验失败提示。
|
||||
func okReply(err error, ok, fail string) botReply {
|
||||
if err != nil {
|
||||
return botReply{Text: fail}
|
||||
}
|
||||
return botReply{Text: ok}
|
||||
}
|
||||
|
||||
// clearState 删除 BotFather 对话状态(忽略错误,仅记日志)。
|
||||
func (s *Service) clearState(ctx context.Context, userID int64) {
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
|
||||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
type ownedBot struct {
|
||||
profile domain.BotProfile
|
||||
user domain.User
|
||||
}
|
||||
|
||||
func (s *Service) ownedBots(ctx context.Context, ownerUserID int64) ([]ownedBot, error) {
|
||||
profiles, err := s.bots.ListBotsByOwner(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
ids = append(ids, p.BotUserID)
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[int64]domain.User, len(users))
|
||||
for _, u := range users {
|
||||
byID[u.ID] = u
|
||||
}
|
||||
out := make([]ownedBot, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
u, ok := byID[p.BotUserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, ownedBot{profile: p, user: u})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].user.ID < out[j].user.ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ownedBotUsernames(ctx context.Context, ownerUserID int64) ([]string, error) {
|
||||
owned, err := s.ownedBots(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(owned))
|
||||
for _, b := range owned {
|
||||
if b.user.Username != "" {
|
||||
out = append(out, b.user.Username)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// tokenReply 拼装带 code entity 的 token 消息(全 ASCII 文本,offset 按字节即按
|
||||
// UTF-16 code unit 成立)。
|
||||
func tokenReply(head, token, tail string) botReply {
|
||||
return botReply{
|
||||
Text: head + token + tail,
|
||||
Entities: []domain.MessageEntity{
|
||||
{Type: domain.MessageEntityCode, Offset: len(head), Length: len(token)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func internalReply() botReply {
|
||||
return botReply{Text: "Something went wrong on my side. Please try again later."}
|
||||
}
|
||||
|
||||
// parseBotCommand 解析行首 "/cmd"(容忍 "/cmd@BotFather" 与尾随参数),返回小写命令名。
|
||||
func parseBotCommand(text string) (string, bool) {
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return "", false
|
||||
}
|
||||
cmd := text[1:]
|
||||
if i := strings.IndexAny(cmd, " \t\n"); i >= 0 {
|
||||
cmd = cmd[:i]
|
||||
}
|
||||
if i := strings.IndexByte(cmd, '@'); i >= 0 {
|
||||
cmd = cmd[:i]
|
||||
}
|
||||
if cmd == "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(cmd), true
|
||||
}
|
||||
413
internal/app/bots/manage_test.go
Normal file
413
internal/app/bots/manage_test.go
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// makeBot 创建一个 owned bot,返回其 user。
|
||||
func makeBot(t *testing.T, svc *Service, owner domain.User, name, username string) domain.User {
|
||||
t.Helper()
|
||||
u, _, err := svc.CreateBot(context.Background(), owner.ID, name, username)
|
||||
if err != nil {
|
||||
t.Fatalf("create bot %q: %v", username, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestSetBotCommandsAndBump(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2000")
|
||||
bot := makeBot(t, svc, owner, "Cmd Bot", "cmd_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
before, _, _ := users.ByID(ctx, bot.ID)
|
||||
v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{
|
||||
{Command: "/Start", Description: "begin"},
|
||||
{Command: "help", Description: "show help"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set commands: %v", err)
|
||||
}
|
||||
if v1 <= before.BotInfoVersion {
|
||||
t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, v1)
|
||||
}
|
||||
got, err := svc.GetBotCommands(ctx, bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get commands: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" {
|
||||
t.Fatalf("commands = %+v, want normalized [start,help]", got)
|
||||
}
|
||||
|
||||
// 非法命令名 → ErrBotCommandInvalid。
|
||||
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "bad name!", Description: "x"}}); err != domain.ErrBotCommandInvalid {
|
||||
t.Fatalf("invalid command err = %v, want ErrBotCommandInvalid", err)
|
||||
}
|
||||
// 空描述 → 非法。
|
||||
if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "ok", Description: ""}}); err != domain.ErrBotCommandInvalid {
|
||||
t.Fatalf("empty desc err = %v, want ErrBotCommandInvalid", err)
|
||||
}
|
||||
// 清空。
|
||||
if _, err := svc.SetBotCommands(ctx, bot.ID, nil); err != nil {
|
||||
t.Fatalf("reset commands: %v", err)
|
||||
}
|
||||
if got, _ := svc.GetBotCommands(ctx, bot.ID); len(got) != 0 {
|
||||
t.Fatalf("after reset commands = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetBotInfoFields(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2001")
|
||||
bot := makeBot(t, svc, owner, "Info Bot", "info_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{
|
||||
SetName: true, Name: "Renamed Bot",
|
||||
SetAbout: true, About: "about line",
|
||||
SetDescription: true, Description: "what this bot does",
|
||||
}); err != nil {
|
||||
t.Fatalf("set bot info: %v", err)
|
||||
}
|
||||
name, about, description, err := svc.GetBotInfo(ctx, bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot info: %v", err)
|
||||
}
|
||||
if name != "Renamed Bot" || about != "about line" || description != "what this bot does" {
|
||||
t.Fatalf("bot info = name=%q about=%q desc=%q", name, about, description)
|
||||
}
|
||||
// name 落到 users.first_name。
|
||||
u, _, _ := users.ByID(ctx, bot.ID)
|
||||
if u.FirstName != "Renamed Bot" || u.About != "about line" {
|
||||
t.Fatalf("user row = first_name=%q about=%q, want name/about persisted", u.FirstName, u.About)
|
||||
}
|
||||
// 空 name 非法。
|
||||
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{SetName: true, Name: " "}); err != domain.ErrBotInfoInvalid {
|
||||
t.Fatalf("empty name err = %v, want ErrBotInfoInvalid", err)
|
||||
}
|
||||
// 全空更新非法。
|
||||
if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{}); err != domain.ErrBotInfoInvalid {
|
||||
t.Fatalf("noop update err = %v, want ErrBotInfoInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetBotMenuButton(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2002")
|
||||
bot := makeBot(t, svc, owner, "Menu Bot", "menu_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{
|
||||
Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://example.com/app",
|
||||
}); err != nil {
|
||||
t.Fatalf("set menu button: %v", err)
|
||||
}
|
||||
btn, err := svc.GetBotMenuButton(ctx, bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get menu button: %v", err)
|
||||
}
|
||||
if btn.Type != domain.BotMenuButtonWebView || btn.Text != "Open" || btn.URL != "https://example.com/app" {
|
||||
t.Fatalf("menu button = %+v", btn)
|
||||
}
|
||||
// webview 缺 text/url 非法。
|
||||
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, URL: "https://x"}); err != domain.ErrBotMenuButtonInvalid {
|
||||
t.Fatalf("webview missing text err = %v, want ErrBotMenuButtonInvalid", err)
|
||||
}
|
||||
// commands 型清空 text/url。
|
||||
if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonCommands, Text: "x", URL: "y"}); err != nil {
|
||||
t.Fatalf("set commands menu: %v", err)
|
||||
}
|
||||
if btn, _ := svc.GetBotMenuButton(ctx, bot.ID); btn.Type != domain.BotMenuButtonCommands || btn.Text != "" || btn.URL != "" {
|
||||
t.Fatalf("commands menu = %+v, want cleared text/url", btn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInlinePlaceholder(t *testing.T) {
|
||||
svc, users, bots, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2010")
|
||||
bot := makeBot(t, svc, owner, "Inline Bot", "inline_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
before, _, _ := users.ByID(ctx, bot.ID)
|
||||
version, err := svc.SetInlinePlaceholder(ctx, bot.ID, "Search things")
|
||||
if err != nil {
|
||||
t.Fatalf("set inline placeholder: %v", err)
|
||||
}
|
||||
if version <= before.BotInfoVersion {
|
||||
t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, version)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search things" {
|
||||
t.Fatalf("inline placeholder = %q, want Search things", p.InlinePlaceholder)
|
||||
}
|
||||
if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, strings.Repeat("x", domain.MaxBotInlinePlaceholderLen+1)); err != domain.ErrBotInlinePlaceholderInvalid {
|
||||
t.Fatalf("overlong placeholder err = %v, want ErrBotInlinePlaceholderInvalid", err)
|
||||
}
|
||||
if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, ""); err != nil {
|
||||
t.Fatalf("clear inline placeholder: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" {
|
||||
t.Fatalf("inline placeholder after clear = %q, want empty", p.InlinePlaceholder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetJoinGroupsAndPrivacy(t *testing.T) {
|
||||
svc, users, bots, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2003")
|
||||
bot := makeBot(t, svc, owner, "Flag Bot", "flag_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
// joingroups disable → nochats=true。
|
||||
if _, err := svc.SetJoinGroups(ctx, bot.ID, false); err != nil {
|
||||
t.Fatalf("set join groups: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats {
|
||||
t.Fatalf("nochats = false, want true after disable join")
|
||||
}
|
||||
if _, err := svc.SetJoinGroups(ctx, bot.ID, true); err != nil {
|
||||
t.Fatalf("re-enable join: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.Nochats {
|
||||
t.Fatalf("nochats = true, want false after enable join")
|
||||
}
|
||||
// privacy enable → chat_history=false(隐私开=只收命令)。
|
||||
if _, err := svc.SetPrivacy(ctx, bot.ID, true); err != nil {
|
||||
t.Fatalf("set privacy: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.ChatHistory {
|
||||
t.Fatalf("chat_history = true, want false when privacy enabled")
|
||||
}
|
||||
if _, err := svc.SetPrivacy(ctx, bot.ID, false); err != nil {
|
||||
t.Fatalf("disable privacy: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.ChatHistory {
|
||||
t.Fatalf("chat_history = false, want true when privacy disabled")
|
||||
}
|
||||
before, _, _ := users.ByID(ctx, bot.ID)
|
||||
version, err := svc.SetInlineGeo(ctx, bot.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("set inline geo: %v", err)
|
||||
}
|
||||
if version <= before.BotInfoVersion {
|
||||
t.Fatalf("bot_info_version not bumped for inline geo: before=%d after=%d", before.BotInfoVersion, version)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo {
|
||||
t.Fatalf("inline_geo = false, want true after enable")
|
||||
}
|
||||
if _, err := svc.SetInlineGeo(ctx, bot.ID, false); err != nil {
|
||||
t.Fatalf("disable inline geo: %v", err)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo {
|
||||
t.Fatalf("inline_geo = true, want false after disable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnsBot(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2004")
|
||||
other := newOwner(t, users, "+2005")
|
||||
bot := makeBot(t, svc, owner, "Owned Bot", "owned_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if owns, err := svc.OwnsBot(ctx, owner.ID, bot.ID); err != nil || !owns {
|
||||
t.Fatalf("owner OwnsBot = %v,%v, want true", owns, err)
|
||||
}
|
||||
if owns, _ := svc.OwnsBot(ctx, other.ID, bot.ID); owns {
|
||||
t.Fatalf("non-owner OwnsBot = true, want false")
|
||||
}
|
||||
// BotFather 自身不算任何人 owned。
|
||||
if owns, _ := svc.OwnsBot(ctx, domain.BotFatherUserID, domain.BotFatherUserID); owns {
|
||||
t.Fatalf("BotFather self OwnsBot = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherSetCommandsFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2006")
|
||||
bot := makeBot(t, svc, owner, "Flow Bot", "flow_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setcommands"); !strings.Contains(reply, "username") {
|
||||
t.Fatalf("/setcommands reply = %q, want pick bot", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "@flow_test_bot"); !strings.Contains(reply, "list of commands") {
|
||||
t.Fatalf("choose reply = %q, want value prompt", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "start - Begin\nhelp - Show help"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("set commands reply = %q, want success", reply)
|
||||
}
|
||||
got, _, _ := bots.GetBot(ctx, bot.ID)
|
||||
if len(got.Commands) != 2 || got.Commands[0].Command != "start" {
|
||||
t.Fatalf("stored commands = %+v, want [start,help]", got.Commands)
|
||||
}
|
||||
// 非法格式(无 -)保留 state 提示重试。
|
||||
sendToBotFather(t, svc, messages, owner, "/setcommands")
|
||||
sendToBotFather(t, svc, messages, owner, "@flow_test_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "noseparator"); !strings.Contains(reply, "Invalid format") {
|
||||
t.Fatalf("invalid format reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherSetNameAndAboutFlow(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2007")
|
||||
bot := makeBot(t, svc, owner, "Name Bot", "name_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setname")
|
||||
sendToBotFather(t, svc, messages, owner, "@name_test_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "Brand New Name"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setname reply = %q", reply)
|
||||
}
|
||||
if u, _, _ := users.ByID(ctx, bot.ID); u.FirstName != "Brand New Name" {
|
||||
t.Fatalf("bot first_name = %q, want renamed", u.FirstName)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setabouttext")
|
||||
sendToBotFather(t, svc, messages, owner, "@name_test_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "my about"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setabouttext reply = %q", reply)
|
||||
}
|
||||
if u, _, _ := users.ByID(ctx, bot.ID); u.About != "my about" {
|
||||
t.Fatalf("bot about = %q, want updated", u.About)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherSetJoinGroupsFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2008")
|
||||
bot := makeBot(t, svc, owner, "Join Bot", "join_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setjoingroups")
|
||||
sendToBotFather(t, svc, messages, owner, "@join_test_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setjoingroups disable reply = %q", reply)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats {
|
||||
t.Fatalf("nochats = false, want true after disable")
|
||||
}
|
||||
// 非 enable/disable 输入保留 state 提示。
|
||||
sendToBotFather(t, svc, messages, owner, "/setprivacy")
|
||||
sendToBotFather(t, svc, messages, owner, "@join_test_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "maybe"); !strings.Contains(reply, "enable") {
|
||||
t.Fatalf("bad toggle reply = %q, want hint", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherSetInlineFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2011")
|
||||
bot := makeBot(t, svc, owner, "Inline Flow", "inline_flow_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setinline"); !strings.Contains(reply, "inline mode") {
|
||||
t.Fatalf("/setinline reply = %q, want pick bot", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "placeholder") {
|
||||
t.Fatalf("choose reply = %q, want placeholder prompt", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "Search inline stuff"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setinline reply = %q, want success", reply)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search inline stuff" {
|
||||
t.Fatalf("inline placeholder = %q", p.InlinePlaceholder)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setinline")
|
||||
sendToBotFather(t, svc, messages, owner, "@inline_flow_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/empty"); !strings.Contains(reply, "disabled") {
|
||||
t.Fatalf("setinline /empty reply = %q, want disabled", reply)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" {
|
||||
t.Fatalf("inline placeholder after /empty = %q, want empty", p.InlinePlaceholder)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setinlinegeo"); !strings.Contains(reply, "location requests") {
|
||||
t.Fatalf("/setinlinegeo reply = %q, want pick bot", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "location") {
|
||||
t.Fatalf("choose inline geo reply = %q, want location prompt", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "enable"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setinlinegeo enable reply = %q, want success", reply)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo {
|
||||
t.Fatalf("inline_geo = false, want enabled")
|
||||
}
|
||||
sendToBotFather(t, svc, messages, owner, "/setinlinegeo")
|
||||
sendToBotFather(t, svc, messages, owner, "@inline_flow_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") {
|
||||
t.Fatalf("setinlinegeo disable reply = %q, want success", reply)
|
||||
}
|
||||
if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo {
|
||||
t.Fatalf("inline_geo = true, want disabled")
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setinlinefeedback"); !strings.Contains(reply, "not supported yet") {
|
||||
t.Fatalf("/setinlinefeedback reply = %q, want explicit stub", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeBotTokenRevokesSessions(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
rev := &captureRevoker{}
|
||||
svc := NewService(users, bots, messages)
|
||||
svc.SetRouterHooks(rev)
|
||||
owner := newOwner(t, users, "+2009")
|
||||
bot := makeBot(t, svc, owner, "Rev Bot", "rev_test_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.RevokeBotToken(ctx, owner.ID, bot.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if rev.botUserID != bot.ID {
|
||||
t.Fatalf("RevokeBotSessions called with %d, want %d", rev.botUserID, bot.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotWriteAccessGrant(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2012")
|
||||
bot := makeBot(t, svc, owner, "Write Bot", "write_access_bot")
|
||||
ctx := context.Background()
|
||||
|
||||
can, err := svc.CanSendMessage(ctx, owner.ID, bot.ID)
|
||||
if err != nil || can {
|
||||
t.Fatalf("CanSendMessage before allow = %v,%v, want false,nil", can, err)
|
||||
}
|
||||
created, err := svc.AllowSendMessage(ctx, owner.ID, bot.ID, true)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("AllowSendMessage first = %v,%v, want true,nil", created, err)
|
||||
}
|
||||
can, err = svc.CanSendMessage(ctx, owner.ID, bot.ID)
|
||||
if err != nil || !can {
|
||||
t.Fatalf("CanSendMessage after allow = %v,%v, want true,nil", can, err)
|
||||
}
|
||||
created, err = svc.AllowSendMessage(ctx, owner.ID, bot.ID, true)
|
||||
if err != nil || created {
|
||||
t.Fatalf("AllowSendMessage repeat = %v,%v, want false,nil", created, err)
|
||||
}
|
||||
}
|
||||
|
||||
type captureRevoker struct {
|
||||
botUserID int64
|
||||
pushedCommandsTo int64
|
||||
pushedCommands []domain.BotCommand
|
||||
}
|
||||
|
||||
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
|
||||
c.botUserID = botUserID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||
c.pushedCommandsTo = botUserID
|
||||
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
|
||||
}
|
||||
373
internal/app/bots/miniapp.go
Normal file
373
internal/app/bots/miniapp.go
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMainAppShortName = "main"
|
||||
requestedWebViewButtonTTL = 10 * time.Minute
|
||||
webViewCustomMethodQueryTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (s *Service) UpsertBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, int, error) {
|
||||
clean, err := s.normalizeBotApp(ctx, botUserID, app)
|
||||
if err != nil {
|
||||
return domain.BotApp{}, 0, err
|
||||
}
|
||||
out, version, err := s.bots.UpsertBotApp(ctx, clean)
|
||||
if err != nil {
|
||||
return domain.BotApp{}, 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return out, version, nil
|
||||
}
|
||||
|
||||
func (s *Service) EnsureMenuBotApp(ctx context.Context, botUserID int64, button domain.BotMenuButton) (domain.BotApp, int, error) {
|
||||
if button.Type != domain.BotMenuButtonWebView {
|
||||
return domain.BotApp{}, 0, nil
|
||||
}
|
||||
app, version, err := s.UpsertBotApp(ctx, botUserID, domain.BotApp{
|
||||
BotUserID: botUserID,
|
||||
ShortName: defaultMainAppShortName,
|
||||
Title: button.Text,
|
||||
URL: button.URL,
|
||||
Main: true,
|
||||
RequestWriteAccess: true,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.BotApp{}, 0, err
|
||||
}
|
||||
if _, err := s.UpsertAttachMenuBot(ctx, botUserID, domain.BotAttachMenuBot{
|
||||
BotUserID: botUserID,
|
||||
AppID: app.ID,
|
||||
ShortName: app.ShortName,
|
||||
RequestWriteAccess: app.RequestWriteAccess,
|
||||
ShowInAttachMenu: true,
|
||||
ShowInSideMenu: true,
|
||||
}); err != nil {
|
||||
return domain.BotApp{}, 0, err
|
||||
}
|
||||
return app, version, nil
|
||||
}
|
||||
|
||||
func (s *Service) normalizeBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.BotApp{}, domain.ErrBotAppInvalid
|
||||
}
|
||||
app.BotUserID = botUserID
|
||||
app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName))
|
||||
app.Title = strings.TrimSpace(app.Title)
|
||||
app.Description = strings.TrimSpace(app.Description)
|
||||
app.URL = strings.TrimSpace(app.URL)
|
||||
if !validBotAppShortName(app.ShortName) {
|
||||
return domain.BotApp{}, domain.ErrBotAppShortNameInvalid
|
||||
}
|
||||
if app.Title == "" || utf8.RuneCountInString(app.Title) > domain.MaxBotAppTitleLen ||
|
||||
utf8.RuneCountInString(app.Description) > domain.MaxBotAppDescriptionLen ||
|
||||
len(app.URL) > domain.MaxBotAppURLLen || !validHTTPSURL(app.URL) {
|
||||
return domain.BotApp{}, domain.ErrBotAppInvalid
|
||||
}
|
||||
if app.ID == 0 {
|
||||
app.ID = stableBotAppInt64("bot-app-id", fmt.Sprint(botUserID), app.ShortName)
|
||||
}
|
||||
if app.AccessHash == 0 {
|
||||
if existing, found, err := s.bots.GetBotAppByShortName(ctx, botUserID, app.ShortName); err == nil && found {
|
||||
app.AccessHash = existing.AccessHash
|
||||
if app.ID == 0 {
|
||||
app.ID = existing.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.AccessHash == 0 {
|
||||
app.AccessHash = stableBotAppInt64("bot-app-access", fmt.Sprint(botUserID), app.ShortName, app.URL)
|
||||
}
|
||||
app.Hash = botAppHash(app)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
return s.bots.GetBotAppByID(ctx, appID, accessHash)
|
||||
}
|
||||
|
||||
func (s *Service) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
return s.bots.GetBotAppByShortName(ctx, botUserID, strings.ToLower(strings.TrimSpace(shortName)))
|
||||
}
|
||||
|
||||
func (s *Service) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
return s.bots.GetMainBotApp(ctx, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.bots.ListBotApps(ctx, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotAppSettings{}, false, nil
|
||||
}
|
||||
return s.bots.GetBotAppSettings(ctx, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
version, err := s.bots.UpsertBotAppSettings(ctx, botUserID, settings)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
if media.ID == 0 {
|
||||
items, err := s.bots.ListBotAppPreviewMedia(ctx, media.BotUserID, media.AppID)
|
||||
if err != nil {
|
||||
return domain.BotAppPreviewMedia{}, 0, err
|
||||
}
|
||||
if len(items) >= domain.MaxBotPreviewMedia {
|
||||
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
}
|
||||
out, version, err := s.bots.UpsertBotAppPreviewMedia(ctx, media)
|
||||
if err != nil {
|
||||
return domain.BotAppPreviewMedia{}, 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, media.BotUserID)
|
||||
return out, version, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) {
|
||||
version, err := s.bots.DeleteBotAppPreviewMedia(ctx, botUserID, appID, mediaID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) {
|
||||
version, err := s.bots.ReorderBotAppPreviewMedia(ctx, botUserID, appID, mediaIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpsertAttachMenuBot(ctx context.Context, botUserID int64, bot domain.BotAttachMenuBot) (int, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return 0, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
bot.BotUserID = botUserID
|
||||
bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName))
|
||||
if bot.ShortName == "" {
|
||||
if app, found, err := s.GetMainBotApp(ctx, botUserID); err == nil && found {
|
||||
bot.AppID = app.ID
|
||||
bot.ShortName = app.ShortName
|
||||
bot.HasSettings = app.HasSettings
|
||||
bot.RequestWriteAccess = app.RequestWriteAccess
|
||||
}
|
||||
}
|
||||
if !validBotAppShortName(bot.ShortName) {
|
||||
return 0, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
if len(bot.PeerTypes) == 0 {
|
||||
bot.PeerTypes = []string{"pm", "chat", "megagroup", "broadcast"}
|
||||
}
|
||||
if len(bot.PeerTypes) > domain.MaxBotAttachMenuPeerTypes || len(bot.Icons) > domain.MaxBotAttachMenuIcons {
|
||||
return 0, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
if !bot.ShowInAttachMenu && !bot.ShowInSideMenu {
|
||||
bot.ShowInAttachMenu = true
|
||||
bot.ShowInSideMenu = true
|
||||
}
|
||||
version, err := s.bots.UpsertAttachMenuBot(ctx, bot)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotAttachMenuBot{}, false, nil
|
||||
}
|
||||
return s.bots.GetAttachMenuBot(ctx, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.bots.ListAttachMenuBots(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotAttachMenuState{}, false, nil
|
||||
}
|
||||
return s.bots.GetAttachMenuState(ctx, userID, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
return s.bots.SetAttachMenuState(ctx, state)
|
||||
}
|
||||
|
||||
func (s *Service) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) (domain.BotRequestedWebViewButton, error) {
|
||||
if s == nil || s.bots == nil || button.BotUserID == 0 || button.UserID == 0 || button.ButtonID == 0 {
|
||||
return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
if button.WebAppReqID == "" {
|
||||
rnd, err := randomInt64()
|
||||
if err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, err
|
||||
}
|
||||
button.WebAppReqID = hex.EncodeToString([]byte(fmt.Sprintf("%d:%d:%d", button.BotUserID, button.UserID, rnd)))
|
||||
}
|
||||
if button.MaxQuantity <= 0 {
|
||||
button.MaxQuantity = 1
|
||||
}
|
||||
if button.MaxQuantity > domain.MaxBotRequestedPeerQuantity {
|
||||
return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
now := s.now()
|
||||
if button.CreatedAt.IsZero() {
|
||||
button.CreatedAt = now
|
||||
}
|
||||
if button.ExpiresAt.IsZero() {
|
||||
button.ExpiresAt = now.Add(requestedWebViewButtonTTL)
|
||||
}
|
||||
if err := s.bots.SaveRequestedWebViewButton(ctx, button); err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, err
|
||||
}
|
||||
return button, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) (domain.BotRequestedWebViewButton, bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.BotRequestedWebViewButton{}, false, nil
|
||||
}
|
||||
return s.bots.GetRequestedWebViewButton(ctx, botUserID, userID, reqID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) error {
|
||||
if s == nil || s.bots == nil {
|
||||
return nil
|
||||
}
|
||||
return s.bots.DeleteRequestedWebViewButton(ctx, botUserID, userID, reqID)
|
||||
}
|
||||
|
||||
func (s *Service) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error {
|
||||
if s == nil || s.bots == nil {
|
||||
return domain.ErrBotNotFound
|
||||
}
|
||||
return s.bots.SetBotEmojiStatusPermission(ctx, botUserID, userID, allowed)
|
||||
}
|
||||
|
||||
func (s *Service) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.bots.BotEmojiStatusPermission(ctx, botUserID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error) {
|
||||
method = strings.TrimSpace(method)
|
||||
if s == nil || s.bots == nil || botUserID == 0 || userID == 0 || method == "" || len(method) > domain.MaxBotCustomMethodLen || len(paramsJSON) > domain.MaxBotCustomMethodPayloadLen {
|
||||
return domain.BotWebViewCustomMethodQuery{}, domain.ErrBotCustomMethodUnavailable
|
||||
}
|
||||
rnd, err := randomInt64()
|
||||
if err != nil {
|
||||
return domain.BotWebViewCustomMethodQuery{}, err
|
||||
}
|
||||
now := s.now()
|
||||
query := domain.BotWebViewCustomMethodQuery{
|
||||
ID: fmt.Sprintf("%d:%d:%d:%d", botUserID, userID, now.UnixNano(), rnd),
|
||||
BotUserID: botUserID,
|
||||
UserID: userID,
|
||||
CustomMethod: method,
|
||||
ParamsJSON: paramsJSON,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(webViewCustomMethodQueryTTL),
|
||||
}
|
||||
if err := s.bots.PutWebViewCustomMethodQuery(ctx, query); err != nil {
|
||||
return domain.BotWebViewCustomMethodQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func validHTTPSURL(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
return err == nil && u.Scheme == "https" && u.Host != ""
|
||||
}
|
||||
|
||||
func validBotAppShortName(shortName string) bool {
|
||||
if shortName == "" || len(shortName) > domain.MaxBotAppShortNameLen {
|
||||
return false
|
||||
}
|
||||
for _, r := range shortName {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func botAppHash(app domain.BotApp) int64 {
|
||||
return stableBotAppInt64("bot-app-hash", fmt.Sprint(app.BotUserID), app.ShortName, app.Title, app.Description, app.URL, fmt.Sprint(app.PhotoID), fmt.Sprint(app.DocumentID), fmt.Sprint(app.Inactive), fmt.Sprint(app.RequestWriteAccess), fmt.Sprint(app.HasSettings), fmt.Sprint(app.Main))
|
||||
}
|
||||
|
||||
func stableBotAppInt64(parts ...string) int64 {
|
||||
h := sha256.New()
|
||||
for _, part := range parts {
|
||||
_, _ = h.Write([]byte(part))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
sum := h.Sum(nil)
|
||||
value := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
|
||||
if value == 0 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
130
internal/app/bots/profile_cache.go
Normal file
130
internal/app/bots/profile_cache.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
botProfileCacheMaxEntries = 100000
|
||||
botProfileCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// botProfileValue 是缓存值,found=false 表示「查过但该 bot 不存在」(负缓存),
|
||||
// 避免未注册 bot 的 id 反复打后端。
|
||||
type botProfileValue struct {
|
||||
profile domain.BotProfile
|
||||
found bool
|
||||
}
|
||||
|
||||
// botProfileCache 由统一缓存原语承载(LRU 单条驱逐 / epoch 守卫 / clone 内建)。
|
||||
// 单个走 GetOrLoad,批量走 GetOrLoadBatch(一次 LoadEpoch + 合批 load + per-key epoch 写回)。
|
||||
type botProfileCache struct {
|
||||
cache *readmodelcache.Cache[int64, botProfileValue]
|
||||
}
|
||||
|
||||
func newBotProfileCache(max int, ttl time.Duration) *botProfileCache {
|
||||
cache := readmodelcache.New[int64, botProfileValue](readmodelcache.Config[int64, botProfileValue]{
|
||||
MaxEntries: max,
|
||||
TTL: ttl,
|
||||
Clone: cloneBotProfileValue,
|
||||
})
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
return &botProfileCache{cache: cache}
|
||||
}
|
||||
|
||||
// getOrLoad 解析单个 bot;load 返回 (profile, found, err)。
|
||||
func (c *botProfileCache) getOrLoad(ctx context.Context, botUserID int64, load func() (domain.BotProfile, bool, error)) (domain.BotProfile, bool, error) {
|
||||
if c == nil || botUserID == 0 {
|
||||
return load()
|
||||
}
|
||||
v, err := c.cache.GetOrLoad(ctx, botUserID, func() (botProfileValue, error) {
|
||||
profile, found, err := load()
|
||||
if err != nil {
|
||||
return botProfileValue{}, err
|
||||
}
|
||||
return botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.BotProfile{}, false, err
|
||||
}
|
||||
return v.profile, v.found, nil
|
||||
}
|
||||
|
||||
// getMany 批量解析;loadMissing 返回 misses 的 profiles(仅存在的;缺失即视为负结果)。
|
||||
// 返回的 map 只含存在(found)的 bot,与旧 getMany 语义一致。
|
||||
func (c *botProfileCache) getMany(ctx context.Context, ids []int64, loadMissing func(context.Context, []int64) (map[int64]domain.BotProfile, error)) (map[int64]domain.BotProfile, error) {
|
||||
unique := uniqueBotUserIDs(ids)
|
||||
if c == nil {
|
||||
return loadMissing(ctx, unique)
|
||||
}
|
||||
values, err := c.cache.GetOrLoadBatch(ctx, unique,
|
||||
func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门
|
||||
func(ctx context.Context, missing []int64) (map[int64]botProfileValue, error) {
|
||||
loaded, err := loadMissing(ctx, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]botProfileValue, len(missing))
|
||||
for _, id := range missing {
|
||||
profile, found := loaded[id]
|
||||
out[id] = botProfileValue{profile: normalizeBotProfile(id, profile, found), found: found}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]domain.BotProfile, len(values))
|
||||
for id, v := range values {
|
||||
if v.found {
|
||||
out[id] = v.profile
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *botProfileCache) put(botUserID int64, profile domain.BotProfile, found bool) {
|
||||
if c == nil || botUserID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.Store(botUserID, botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found})
|
||||
}
|
||||
|
||||
func (c *botProfileCache) delete(botUserID int64) {
|
||||
if c == nil || botUserID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.Invalidate(botUserID)
|
||||
}
|
||||
|
||||
func (c *botProfileCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func normalizeBotProfile(botUserID int64, profile domain.BotProfile, found bool) domain.BotProfile {
|
||||
if found && profile.BotUserID == 0 {
|
||||
profile.BotUserID = botUserID
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func cloneBotProfileValue(v botProfileValue) botProfileValue {
|
||||
v.profile = cloneBotProfile(v.profile)
|
||||
return v
|
||||
}
|
||||
|
||||
func cloneBotProfile(profile domain.BotProfile) domain.BotProfile {
|
||||
if len(profile.Commands) > 0 {
|
||||
profile.Commands = append([]domain.BotCommand(nil), profile.Commands...)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
645
internal/app/bots/service.go
Normal file
645
internal/app/bots/service.go
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
// Package bots 实现 bot 账号业务:BotFather 对话状态机、bot 创建、token 管理与
|
||||
// botInfo 查询。bot 登录(auth.importBotAuthorization)在 app/auth 经 store.BotStore
|
||||
// 直接校验 token,不依赖本包。
|
||||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// blockChecker 报告 userID 是否 block 了 blockedUserID(store.ContactStore 子集)。
|
||||
type blockChecker interface {
|
||||
IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error)
|
||||
}
|
||||
|
||||
type publicChannelUsernameResolver interface {
|
||||
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
|
||||
}
|
||||
|
||||
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||
// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现):
|
||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||
// authorization + 强制断连)。
|
||||
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
|
||||
// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。
|
||||
type RouterHooks interface {
|
||||
RevokeBotSessions(ctx context.Context, botUserID int64) error
|
||||
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
|
||||
}
|
||||
|
||||
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
|
||||
// 串行执行(状态机 RMW 原子 + 回复保序),不同用户并发;固定大小不随用户数增长。
|
||||
const replyLockStripes = 256
|
||||
|
||||
// Service 提供 bot 账号业务。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
bots store.BotStore
|
||||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
hooks RouterHooks
|
||||
userCache store.UserCache
|
||||
cache *botProfileCache
|
||||
log *zap.Logger
|
||||
now func() time.Time
|
||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||
replySeq atomic.Int64
|
||||
replyLocks [replyLockStripes]sync.Mutex
|
||||
}
|
||||
|
||||
// Option 调整 bots 服务的可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithLogger 注入日志器(缺省 zap.NewNop)。
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithNow 注入时钟(测试用)。
|
||||
func WithNow(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithBlockChecker 注入 block 关系查询:BotFather 回复前据此设置 RecipientBlocked,
|
||||
// 用户 block 掉 BotFather 后不再向其收件箱投递(对齐 rpc 发送路径语义)。
|
||||
func WithBlockChecker(c blockChecker) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.blocker = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
|
||||
// username 预检,避免 bot 与 public channel 产生同名可见入口。
|
||||
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.channels = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserCache 注入 users 基础资料缓存:bot 元数据写入(first_name/about/
|
||||
// bot_info_version bump)后必须失效该 bot 的缓存条目,否则 TTL 内 getUsers
|
||||
// 返回旧 first_name 与旧 bot_info_version——version bump 被缓存遮蔽,客户端
|
||||
// 感知不到变更、不会重拉 getFullUser。
|
||||
func WithUserCache(c store.UserCache) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.userCache = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||
if s.userCache == nil {
|
||||
return
|
||||
}
|
||||
if err := s.userCache.Delete(ctx, []int64{botUserID}); err != nil {
|
||||
s.log.Warn("invalidate bot user cache", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) invalidateBotProfileCache(botUserID int64) {
|
||||
if s.cache != nil {
|
||||
s.cache.delete(botUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) invalidateBotReadCaches(ctx context.Context, botUserID int64) {
|
||||
s.invalidateBotProfileCache(botUserID)
|
||||
s.invalidateUserCache(ctx, botUserID)
|
||||
}
|
||||
|
||||
// InvalidateBotProfileReadModel 供 ReadModelChangeListener 在 user_base 事件(bot 写会
|
||||
// bump bot_info_version)时跨实例失效本进程 bot 资料缓存。
|
||||
func (s *Service) InvalidateBotProfileReadModel(userID int64) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.invalidateBotProfileCache(userID)
|
||||
}
|
||||
|
||||
// FlushBotProfileReadModel 供 listener 重连时整表 flush,兜住断连窗口内丢失的 user_base 通知。
|
||||
func (s *Service) FlushBotProfileReadModel() {
|
||||
if s == nil || s.cache == nil {
|
||||
return
|
||||
}
|
||||
s.cache.flush()
|
||||
}
|
||||
|
||||
// SetRouterHooks 注入 rpc 层回调(router 创建后装配,与 P1 的
|
||||
// SetLifecycleObserver 同款延迟注入)。
|
||||
func (s *Service) SetRouterHooks(h RouterHooks) {
|
||||
if s != nil {
|
||||
s.hooks = h
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 bots 服务。
|
||||
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
users: users,
|
||||
bots: bots,
|
||||
messages: messages,
|
||||
cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL),
|
||||
log: zap.NewNop(),
|
||||
now: time.Now,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) botProfile(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.BotProfile{}, false, nil
|
||||
}
|
||||
if s.cache != nil {
|
||||
return s.cache.getOrLoad(ctx, botUserID, func() (domain.BotProfile, bool, error) {
|
||||
return s.bots.GetBot(ctx, botUserID)
|
||||
})
|
||||
}
|
||||
return s.bots.GetBot(ctx, botUserID)
|
||||
}
|
||||
|
||||
func (s *Service) botProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
|
||||
if s == nil || s.bots == nil || len(botUserIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := uniqueBotUserIDs(botUserIDs)
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if s.cache == nil {
|
||||
return s.loadBotProfiles(ctx, ids)
|
||||
}
|
||||
return s.cache.getMany(ctx, ids, s.loadBotProfiles)
|
||||
}
|
||||
|
||||
func (s *Service) loadBotProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
|
||||
if batch, ok := s.bots.(botBatchStore); ok {
|
||||
return batch.GetBots(ctx, botUserIDs)
|
||||
}
|
||||
out := make(map[int64]domain.BotProfile)
|
||||
for _, id := range uniqueBotUserIDs(botUserIDs) {
|
||||
profile, found, err := s.bots.GetBot(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out[id] = profile
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BotInfo 返回 bot 的元数据(userFull.bot_info hydrate 用)。
|
||||
func (s *Service) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
return s.botProfile(ctx, botUserID)
|
||||
}
|
||||
|
||||
type botBatchStore interface {
|
||||
GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
|
||||
}
|
||||
|
||||
// BotInfos 批量返回 bot 元数据,供频道 full info / participants 这类高频富化路径避免逐 bot 点查。
|
||||
func (s *Service) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
|
||||
return s.botProfiles(ctx, botUserIDs)
|
||||
}
|
||||
|
||||
func uniqueBotUserIDs(ids []int64) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckUsername 校验 bot username 语法与全局可见入口占用(users + public channels)。
|
||||
func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username string) (bool, error) {
|
||||
if s == nil || s.users == nil || ownerUserID == 0 {
|
||||
return false, domain.ErrBotUsernameInvalid
|
||||
}
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
if !domain.ValidBotUsername(username) {
|
||||
return false, domain.ErrBotUsernameInvalid
|
||||
}
|
||||
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
return false, nil
|
||||
}
|
||||
if s.channels != nil {
|
||||
if _, found, err := s.channels.ResolvePublicChannelUsername(ctx, ownerUserID, username); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateBot 创建一个新 bot 账号:users 行(is_bot, bot_info_version=1, 无 phone)+
|
||||
// bots 行(owner、token)。返回新账号与完整 token(唯一一次返回明文的途径之一)。
|
||||
func (s *Service) CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) {
|
||||
if s == nil || s.users == nil || s.bots == nil || ownerUserID == 0 {
|
||||
return domain.User{}, "", domain.ErrBotNameInvalid
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > domain.MaxBotNameLength {
|
||||
return domain.User{}, "", domain.ErrBotNameInvalid
|
||||
}
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
if !domain.ValidBotUsername(username) {
|
||||
return domain.User{}, "", domain.ErrBotUsernameInvalid
|
||||
}
|
||||
ok, err := s.CheckUsername(ctx, ownerUserID, username)
|
||||
if err != nil {
|
||||
return domain.User{}, "", err
|
||||
}
|
||||
if !ok {
|
||||
return domain.User{}, "", domain.ErrUsernameOccupied
|
||||
}
|
||||
count, err := s.bots.CountBotsByOwner(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, "", err
|
||||
}
|
||||
if count >= domain.MaxBotsPerOwner {
|
||||
return domain.User{}, "", domain.ErrBotsTooMany
|
||||
}
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
return domain.User{}, "", err
|
||||
}
|
||||
secret, err := randomTokenSecret()
|
||||
if err != nil {
|
||||
return domain.User{}, "", err
|
||||
}
|
||||
u, profile, err := s.bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: accessHash,
|
||||
FirstName: name,
|
||||
Username: username,
|
||||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
}, domain.BotProfile{
|
||||
OwnerUserID: ownerUserID,
|
||||
TokenSecret: secret,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.User{}, "", err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.put(u.ID, profile, true)
|
||||
}
|
||||
return u, domain.FormatBotToken(u.ID, secret), nil
|
||||
}
|
||||
|
||||
// ListOwnedBots 返回当前 owner 管理的 bot 用户列表(排除 BotFather 种子)。
|
||||
func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error) {
|
||||
owned, err := s.ownedBots(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.User, 0, len(owned))
|
||||
for _, item := range owned {
|
||||
out = append(out, item.user)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
return s.RevokeBotToken(ctx, ownerUserID, botUserID)
|
||||
}
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID || profile.TokenSecret == "" {
|
||||
return "", domain.ErrBotNotFound
|
||||
}
|
||||
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
|
||||
}
|
||||
|
||||
// RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有
|
||||
// 已凭旧 token 登录的 session(经注入的 SessionRevoker)。
|
||||
func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) {
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID {
|
||||
return "", domain.ErrBotNotFound
|
||||
}
|
||||
secret, err := randomTokenSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.bots.UpdateBotTokenSecret(ctx, botUserID, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.invalidateBotProfileCache(botUserID)
|
||||
token := domain.FormatBotToken(botUserID, secret)
|
||||
// 撤销已登录 session:旧 token 已不可重新登录,但已建立的连接仍持有 auth_key,
|
||||
// 必须主动失效(删 authorization + 断连),否则旧持有者继续以 bot 身份操作。
|
||||
// secret 已轮换不可回滚,故失败时仍返回新 token,但透出 ErrBotSessionsNotRevoked
|
||||
// 让调用方诚实告知用户「需重试以确保旧 session 终止」,绝不谎称已止血。
|
||||
if s.hooks != nil {
|
||||
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
|
||||
s.log.Warn("revoke bot sessions", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return token, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// SetBotCommands 覆盖式写入 bot 的 default scope 命令(bots.setBotCommands /
|
||||
// BotFather /setcommands 共用收口)。校验命令名/描述/数量;写库(含 version bump)
|
||||
// 成功后给在线相关用户推 updateBotCommands。返回 bump 后的 bot_info_version。
|
||||
func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
|
||||
if len(commands) > domain.MaxBotCommands {
|
||||
return 0, domain.ErrBotCommandInvalid
|
||||
}
|
||||
clean := make([]domain.BotCommand, 0, len(commands))
|
||||
for _, c := range commands {
|
||||
cmd := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(c.Command, "/")))
|
||||
desc := strings.TrimSpace(c.Description)
|
||||
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
|
||||
return 0, domain.ErrBotCommandInvalid
|
||||
}
|
||||
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc})
|
||||
}
|
||||
// 同值短路:bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
|
||||
// bot_info_version bump(驱动全体客户端多打一轮 getFullUser)与多余推送。
|
||||
// 非原子(读后他写不影响正确性:要么对方已 bump、要么我们多 bump 一次)。
|
||||
cur, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
if botCommandsEqual(cur.Commands, clean) {
|
||||
return 0, nil // 无变更;调用方忽略返回的 version
|
||||
}
|
||||
version, err := s.bots.UpdateBotCommands(ctx, botUserID, clean)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
if s.hooks != nil {
|
||||
s.hooks.PushBotCommandsChanged(ctx, botUserID, clean)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func botCommandsEqual(a, b []domain.BotCommand) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Command != b[i].Command || a[i].Description != b[i].Description {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetBotCommands 返回 bot 的 default scope 命令。
|
||||
func (s *Service) GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) {
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, domain.ErrBotNotFound
|
||||
}
|
||||
return profile.Commands, nil
|
||||
}
|
||||
|
||||
// SetBotInfo 更新 bot 的 name(users.first_name)/about(users.about)/description
|
||||
// (bots.description),返回 bump 后的 bot_info_version。
|
||||
func (s *Service) SetBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
|
||||
if upd.SetName {
|
||||
upd.Name = strings.TrimSpace(upd.Name)
|
||||
if upd.Name == "" || utf8.RuneCountInString(upd.Name) > domain.MaxBotNameLength {
|
||||
return 0, domain.ErrBotInfoInvalid
|
||||
}
|
||||
}
|
||||
if upd.SetAbout && utf8.RuneCountInString(upd.About) > domain.MaxBotAboutLen {
|
||||
return 0, domain.ErrBotInfoInvalid
|
||||
}
|
||||
if upd.SetDescription && utf8.RuneCountInString(upd.Description) > domain.MaxBotDescriptionLen {
|
||||
return 0, domain.ErrBotInfoInvalid
|
||||
}
|
||||
if !upd.SetName && !upd.SetAbout && !upd.SetDescription {
|
||||
return 0, domain.ErrBotInfoInvalid
|
||||
}
|
||||
version, err := s.bots.UpdateBotInfo(ctx, botUserID, upd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// GetBotInfo 返回 bot 的 name/about/description(name=users.first_name、
|
||||
// about=users.about、description=bots.description)。
|
||||
func (s *Service) GetBotInfo(ctx context.Context, botUserID int64) (name, about, description string, err error) {
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if !found {
|
||||
return "", "", "", domain.ErrBotNotFound
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, botUserID)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if !found {
|
||||
return "", "", "", domain.ErrBotNotFound
|
||||
}
|
||||
return u.FirstName, u.About, profile.Description, nil
|
||||
}
|
||||
|
||||
// SetBotMenuButton 设置 bot 的 menu button(per-bot 全局),返回新 bot_info_version。
|
||||
func (s *Service) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
|
||||
switch button.Type {
|
||||
case domain.BotMenuButtonDefault, domain.BotMenuButtonCommands:
|
||||
button.Text, button.URL = "", ""
|
||||
case domain.BotMenuButtonWebView:
|
||||
button.Text = strings.TrimSpace(button.Text)
|
||||
button.URL = strings.TrimSpace(button.URL)
|
||||
if button.Text == "" || len(button.Text) > domain.MaxBotMenuButtonTextLen ||
|
||||
button.URL == "" || len(button.URL) > domain.MaxBotMenuButtonURLLen {
|
||||
return 0, domain.ErrBotMenuButtonInvalid
|
||||
}
|
||||
// 强制 https(对齐官方 BUTTON_URL_INVALID):menu button URL 经
|
||||
// userFull.bot_info.menu_button 下发给所有交互用户的客户端 webview 入口,
|
||||
// 拒绝 javascript:/file:/intent: 等非 https scheme,防 bot 投毒。
|
||||
if u, err := url.Parse(button.URL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return 0, domain.ErrBotMenuButtonInvalid
|
||||
}
|
||||
default:
|
||||
return 0, domain.ErrBotMenuButtonInvalid
|
||||
}
|
||||
version, err := s.bots.UpdateBotMenuButton(ctx, botUserID, button)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if button.Type == domain.BotMenuButtonWebView {
|
||||
if _, _, err := s.EnsureMenuBotApp(ctx, botUserID, button); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// GetBotMenuButton 返回 bot 的 menu button。
|
||||
func (s *Service) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) {
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.BotMenuButton{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.BotMenuButton{}, domain.ErrBotNotFound
|
||||
}
|
||||
return profile.MenuButton, nil
|
||||
}
|
||||
|
||||
// SetInlinePlaceholder 设置 inline mode placeholder;空字符串表示关闭 inline mode。
|
||||
func (s *Service) SetInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) {
|
||||
placeholder = strings.TrimSpace(placeholder)
|
||||
if utf8.RuneCountInString(placeholder) > domain.MaxBotInlinePlaceholderLen {
|
||||
return 0, domain.ErrBotInlinePlaceholderInvalid
|
||||
}
|
||||
version, err := s.bots.SetBotInlinePlaceholder(ctx, botUserID, placeholder)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// SetInlineGeo 设置 bot 是否可在 inline query 中接收用户位置。
|
||||
func (s *Service) SetInlineGeo(ctx context.Context, botUserID int64, enabled bool) (int, error) {
|
||||
version, err := s.bots.SetBotInlineGeo(ctx, botUserID, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// SetJoinGroups 设置 bot 能否被加入群组(allow=true → bot_nochats=false)。
|
||||
func (s *Service) SetJoinGroups(ctx context.Context, botUserID int64, allow bool) (int, error) {
|
||||
version, err := s.bots.SetBotNochats(ctx, botUserID, !allow)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// SetPrivacy 设置 bot 群内 privacy mode(enabled=true 隐私模式开 → bot_chat_history=false,
|
||||
// 即 bot 只看命令/回复;enabled=false → 关闭隐私 → bot_chat_history=true,能看全部消息)。
|
||||
func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool) (int, error) {
|
||||
version, err := s.bots.SetBotChatHistory(ctx, botUserID, !enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// CanSendMessage reports whether botUserID has explicit permission to initiate
|
||||
// direct messages with userID.
|
||||
func (s *Service) CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) {
|
||||
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
|
||||
return false, nil
|
||||
}
|
||||
return s.bots.CanBotSendMessage(ctx, botUserID, userID)
|
||||
}
|
||||
|
||||
// AllowSendMessage records an explicit user grant for botUserID to message userID.
|
||||
func (s *Service) AllowSendMessage(ctx context.Context, userID, botUserID int64, fromRequest bool) (bool, error) {
|
||||
if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID {
|
||||
return false, domain.ErrBotNotFound
|
||||
}
|
||||
return s.bots.AllowBotSendMessage(ctx, botUserID, userID, fromRequest)
|
||||
}
|
||||
|
||||
// OwnsBot 报告 ownerUserID 是否为 botUserID 的 owner(非 BotFather 自身)。
|
||||
func (s *Service) OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
|
||||
profile, found, err := s.botProfile(ctx, botUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found && profile.OwnerUserID == ownerUserID && botUserID != domain.BotFatherUserID, nil
|
||||
}
|
||||
|
||||
// tokenSecretAlphabet 对齐官方 token 随机段字符集。
|
||||
const tokenSecretAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
|
||||
|
||||
func randomTokenSecret() (string, error) {
|
||||
raw := make([]byte, domain.BotTokenSecretLength)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
out := make([]byte, len(raw))
|
||||
for i, b := range raw {
|
||||
out[i] = tokenSecretAlphabet[int(b)%len(tokenSecretAlphabet)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
v := int64(uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]))
|
||||
return v, nil
|
||||
}
|
||||
438
internal/app/bots/service_test.go
Normal file
438
internal/app/bots/service_test.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
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 sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc.respondAsBotFather(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
|
||||
}
|
||||
|
||||
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 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, "@limit0_bot") {
|
||||
t.Fatalf("/mybots reply = %q, want bot list", 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, "/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, "/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue