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:
parent
196f90f9b1
commit
f474a360d7
10 changed files with 1467 additions and 26 deletions
|
|
@ -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==owner,bot 发出的
|
||||
// 那一份 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 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue