chore: refresh gramsrv public release

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

View file

@ -0,0 +1,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 报告该收件人是否为内置应答 botmessages.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 并设置 joingroupsjoin=true或 privacyjoin=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
}

View 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...)
}

View 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
}

View 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
}

View 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 了 blockedUserIDstore.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 层实现):
// - RevokeBotSessionstoken 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 tokenrevoke=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 的 nameusers.first_name/aboutusers.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/descriptionname=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 buttonper-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_INVALIDmenu 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 modeenabled=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
}

View 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)
}
}
}