Interactive /mybots menu for @BotFather

Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
This commit is contained in:
Astra 2026-09-08 13:31:05 +01:00
parent 196f90f9b1
commit f474a360d7
10 changed files with 1467 additions and 26 deletions

View file

@ -26,6 +26,7 @@ const (
botFatherCmdToken = "token"
botFatherCmdRevoke = "revoke"
botFatherCmdSetName = "setname"
botFatherCmdSetBotpic = "setbotpic"
botFatherCmdSetDescription = "setdescription"
botFatherCmdSetAbout = "setabouttext"
botFatherCmdSetCommands = "setcommands"
@ -118,7 +119,7 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
}
switch botUserID {
case domain.BotFatherUserID:
go s.respondAsBotFather(userID, msg.Body)
go s.respondAsBotFather(userID, msg)
case domain.StickersBotUserID:
go s.respondAsStickers(userID, msg)
case domain.ChatBotUserID:
@ -133,14 +134,14 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
// respondAsBotFather 生成并写入 BotFather 回复OnPrivateMessage 在 goroutine 内调用)。
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
// 不同用户并发不受影响。ctx 用 Background脱离已返回的用户 RPC限较长超时。
func (s *Service) respondAsBotFather(userID int64, body string) {
func (s *Service) respondAsBotFather(userID int64, msg domain.Message) {
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
reply := s.handleBotFather(ctx, userID, body)
reply := s.handleBotFather(ctx, userID, msg)
s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply)
}
@ -198,6 +199,45 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
return res, true
}
// editServiceBotMessage 就地改写一条内置 bot 自己发出的消息(正文 + inline keyboard
// 用于按钮式菜单(@BotFather /mybots在点击后原地翻页/下钻,而不是刷屏新消息。
// OwnerUserID 取 bot 自身store 的 authorEdit 判定要求 sender==from==ownerbot 发出的
// 那一份 outbox 拷贝正好满足。ErrMessageNotModified 视为成功(点了同一个按钮两次)。
func (s *Service) editServiceBotMessage(ctx context.Context, botUserID, userID int64, messageID int, reply botReply) bool {
if s == nil || s.messages == nil || messageID <= 0 || reply.Text == "" {
return false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
s.log.Error("service bot: invalid reply markup for edit",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
_, err := s.messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: botUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
ID: messageID,
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
EditDate: int(s.now().Unix()),
HideEdited: true,
SetReplyMarkup: true,
ReplyMarkup: markup,
})
if errors.Is(err, domain.ErrMessageNotModified) {
return true
}
if err != nil {
s.log.Error("service bot: edit reply",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return false
}
return true
}
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
@ -226,8 +266,8 @@ var botFatherGlobalCommands = map[string]bool{
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
text = strings.TrimSpace(text)
func (s *Service) handleBotFather(ctx context.Context, userID int64, msg domain.Message) botReply {
text := strings.TrimSpace(msg.Body)
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))
@ -244,6 +284,11 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string
if text == "" {
// 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示,
// 无状态保持沉默(避免对任意非文本消息刷屏)。
if found && state.Step == botFatherStepValue && msg.Media != nil {
// A photo for /setbotpic arrives with no caption: hand the message to
// the value step instead of replaying the prompt.
return s.handleSetValue(ctx, state, msg)
}
if !found {
return botReply{}
}
@ -260,7 +305,9 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string
case state.Step == botFatherStepChoose:
return s.handleChooseBot(ctx, state, text)
case state.Step == botFatherStepValue:
return s.handleSetValue(ctx, state, text)
return s.handleSetValue(ctx, state, msg)
case state.Command == mybotsCommand:
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
default:
// 不可达的脏状态:清掉重来,避免用户被卡死。
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
@ -318,6 +365,8 @@ func (s *Service) stepPrompt(state domain.BotChatState) botReply {
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])}
case state.Command == mybotsCommand:
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
default:
return botReply{Text: "Send /help for a list of commands."}
}
@ -367,15 +416,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
}
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@")}
return s.startMyBots(ctx, userID)
case botFatherCmdToken, botFatherCmdRevoke,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
@ -395,6 +436,8 @@ func valuePrompt(cmd, username string) string {
switch cmd {
case botFatherCmdSetName:
return fmt.Sprintf("OK. Send me the new name for @%s.", username)
case botFatherCmdSetBotpic:
return fmt.Sprintf("OK. Send me the new profile picture for @%s. Send it as a photo.", 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:
@ -581,8 +624,10 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
}
}
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply {
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。除 /setbotpic 收一张
// 照片外都是纯文本;照片经 msg.Media 传入。
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
text := strings.TrimSpace(msg.Body)
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
username := state.Draft[botFatherDraftBotUsername]
if botID == 0 {
@ -622,6 +667,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
reply, err = s.applyToggle(ctx, botID, text, true)
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetBotpic:
reply, err = s.applySetBotpic(ctx, botID, username, msg)
case botFatherCmdSetLogin:
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
default:
@ -960,6 +1007,25 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
}
// applySetBotpic 把用户发给 BotFather 的一张照片设为 bot 头像。只收 photo不收
// 文件/贴纸);文件层重渲染成头像尺寸集。
func (s *Service) applySetBotpic(ctx context.Context, botID int64, username string, msg domain.Message) (botReply, error) {
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPhoto || msg.Media.Photo == nil || msg.Media.Photo.ID == 0 {
return botReply{Text: "Please send a photo (as a photo, not a file), or /cancel."}, domain.ErrPhotoInvalid
}
err := s.SetBotUserpic(ctx, botID, msg.Media.Photo.ID)
switch {
case errors.Is(err, ErrBotUserpicUnsupported):
return botReply{Text: "Setting a profile picture isn't available on this server."}, err
case errors.Is(err, domain.ErrPhotoInvalid):
return botReply{Text: "Sorry, I couldn't use that image. Send a JPEG or PNG photo, or /cancel."}, err
case err != nil:
s.log.Error("botfather: set botpic", zap.Int64("bot_user_id", botID), zap.Error(err))
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Success! Profile picture updated for @%s.", username)}, 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