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 1ebf8ddb5a
commit 11142f74c5
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"
@ -115,7 +116,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:
@ -130,14 +131,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)
}
@ -194,6 +195,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 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
@ -222,8 +262,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))
@ -240,6 +280,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{}
}
@ -256,7 +301,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)
@ -314,6 +361,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."}
}
@ -363,15 +412,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,
@ -391,6 +432,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:
@ -577,8 +620,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 {
@ -618,6 +663,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:
@ -956,6 +1003,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

729
internal/app/bots/mybots.go Normal file
View file

@ -0,0 +1,729 @@
package bots
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// @BotFather 的按钮式 /mybots列出 owner 的 bot每行一个 + 上一页/下一页),点进
// 单个 bot 后是 API Token / Edit Bot / Bot Settings / Delete Bot 四个入口。除了
// "Edit Bot" 下的文本字段(改名/简介/描述/命令列表,交回既有 handleSetValue 收值
// 流程)之外,全部翻页与下钻都在同一条消息上就地 EditMessage不刷屏。
//
// 会话状态 domain.BotChatStateCommand="mybots"),与其余 @BotFather 流程一样在
// per-user serviceBotReplyLock 下 Get→改→Upsert回调按用户串行、保序。
//
// 安全:回调 data 只带一个随机 token映射到 *该用户自己* BotChatState.Draft 里
// 记下的 choice——data 里那个 bot id 是服务端渲染键盘时写进去的,不是客户端传的。
// 即便如此,每个动作执行前仍重新按 owner 解析一次myBotForUser 只返回 owner 名下
// 的 bot伪造/重放的 token 顶多命中一个不在自己表里的 key被直接拒绝。
const (
mybotsCommand = "mybots"
mybotsStepMenu = "menu"
mybotsDraftGeneration = "gen"
mybotsDraftPage = "page"
mybotsDraftOptionPrefix = "opt:"
// mybotsCallbackDataPrefix tags this menu's callback data. It carries no
// information beyond "this is a @BotFather /mybots button".
mybotsCallbackDataPrefix = "bf:"
mybotsOptionTokenBytes = 6
mybotsOptionTokenMaxLen = 32
// mybotsTokenGenerations is how many renders' worth of button tokens stay
// resolvable: the current render plus the two before it, so a second press of
// the same button (or a button on the message just above) still resolves while
// the table stays bounded.
mybotsTokenGenerations = 3
// mybotsPageSize bounds one page of the bot list. MaxMarkupButtonsPerRow is 8
// and one bot is one row, so this stays well inside the keyboard limits with
// room for the navigation row.
mybotsPageSize = 10
)
// Opaque button choices as stored in the per-user token table. These strings
// never travel over the wire; only the random token that maps to them does.
const (
mybotsChoiceListPrefix = "list:" // list:<page>
mybotsChoiceBotPrefix = "bot:" // bot:<botID> -> per-bot menu
mybotsChoiceTokenPrefix = "tok:" // tok:<botID> -> API token screen
mybotsChoiceRevokePrefix = "rvk:" // rvk:<botID> -> revoke confirm
mybotsChoiceRevokeGoPrefix = "rvkgo:" // rvkgo:<botID> -> do revoke
mybotsChoiceEditPrefix = "edit:" // edit:<botID> -> Edit Bot menu
mybotsChoiceSetNamePrefix = "setname:" // setname:<botID>
mybotsChoiceSetAboutPrefix = "setabout:" // setabout:<botID>
mybotsChoiceSetDescPrefix = "setdesc:" // setdesc:<botID>
mybotsChoiceSetCmdsPrefix = "setcmds:" // setcmds:<botID>
mybotsChoiceBotpicPrefix = "botpic:" // botpic:<botID> -> phase 2, alert for now
mybotsChoiceCfgPrefix = "cfg:" // cfg:<botID> -> Bot Settings screen
mybotsChoiceCfgInline = "cfginl:" // cfginl:<botID> -> toggle inline mode
mybotsChoiceCfgGroups = "cfggrp:" // cfggrp:<botID> -> toggle allow groups
mybotsChoiceCfgPrivacy = "cfgprv:" // cfgprv:<botID> -> toggle group privacy
mybotsChoiceDeletePrefix = "del:" // del:<botID> -> delete confirm
mybotsChoiceDeleteGoPrefix = "delgo:" // delgo:<botID> -> do delete
)
const (
mybotsExpiredButtonText = "That button is no longer active. Send /mybots to open the list again."
mybotsGoneBotText = "That bot is no longer available. Send /mybots to open the list again."
mybotsNoBotsText = "You don't have any bots yet. Use /newbot to create one."
)
// mybotsOption is one inline button before its token is minted.
type mybotsOption struct {
text string
choice string
style domain.MarkupButtonStyle
}
// ---------------------------------------------------------------------------
// Entry: /mybots
// ---------------------------------------------------------------------------
// startMyBots answers /mybots (and the /mybots button path). It sends a fresh
// message carrying the first page of the picker; every later press edits that
// message in place.
func (s *Service) startMyBots(ctx context.Context, userID int64) botReply {
owned, err := s.ownedBots(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if len(owned) == 0 {
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: mybotsNoBotsText}
}
state := domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: mybotsCommand,
Step: mybotsStepMenu,
Draft: map[string]string{},
}
reply, ok := s.myBotsListScreen(ctx, &state, owned, 0)
if !ok {
return internalReply()
}
if !s.saveMyBotsState(ctx, state) {
return internalReply()
}
return reply
}
// ---------------------------------------------------------------------------
// Callback entry point
// ---------------------------------------------------------------------------
// onBotFatherCallback answers one inline-button click on a @BotFather /mybots
// message. It is reached from OnCallbackQuery (verifybot.go).
func (s *Service) onBotFatherCallback(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) {
userID := query.UserID
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
mu.Lock()
defer mu.Unlock()
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state for callback", zap.Int64("user_id", userID), zap.Error(err))
return domain.BotCallbackAnswer{}, true, err
}
if !found || state.Command != mybotsCommand {
return mybotsAlert(mybotsExpiredButtonText), true, nil
}
choice, ok := mybotsResolveOption(state, query.Data)
if !ok {
// Callback data absent from this user's own token table: an expired
// generation, or replayed/fabricated data. All refused identically, and
// nothing changes.
return mybotsAlert(mybotsExpiredButtonText), true, nil
}
// The follow-up write is on a context detached from the caller's RPC: the
// answer unblocks the click, and the edit must survive the client hanging up
// straight afterwards.
sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
return s.applyMyBotsChoice(sendCtx, state, choice, query), true, nil
}
// applyMyBotsChoice executes one resolved button choice. Each branch owns its own
// messaging: an in-place edit of query.MessageID for navigation, a fresh message
// when handing off to the text-input flow, or an alert for a refusal.
func (s *Service) applyMyBotsChoice(ctx context.Context, state domain.BotChatState, choice string, query domain.BotCallbackQuery) domain.BotCallbackAnswer {
edit := func(reply botReply) domain.BotCallbackAnswer {
if reply.Text == "" {
return domain.BotCallbackAnswer{}
}
if !s.saveMyBotsState(ctx, state) {
return mybotsAlert(mybotsExpiredButtonText)
}
s.editServiceBotMessage(ctx, domain.BotFatherUserID, state.UserID, query.MessageID, reply)
return domain.BotCallbackAnswer{}
}
switch {
case strings.HasPrefix(choice, mybotsChoiceListPrefix):
page, _ := strconv.Atoi(strings.TrimPrefix(choice, mybotsChoiceListPrefix))
owned, 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 mybotsAlert(mybotsExpiredButtonText)
}
if len(owned) == 0 {
return edit(s.myBotsEmptyScreen(&state))
}
reply, ok := s.myBotsListScreen(ctx, &state, owned, page)
if !ok {
return mybotsAlert(mybotsExpiredButtonText)
}
return edit(reply)
case strings.HasPrefix(choice, mybotsChoiceBotPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceBotPrefix, edit, func(b ownedBot) botReply {
return s.myBotsBotMenu(&state, b)
})
case strings.HasPrefix(choice, mybotsChoiceTokenPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceTokenPrefix, edit, func(b ownedBot) botReply {
return s.myBotsTokenScreen(ctx, &state, b, "")
})
case strings.HasPrefix(choice, mybotsChoiceRevokePrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceRevokePrefix, edit, func(b ownedBot) botReply {
return s.myBotsRevokeConfirm(&state, b)
})
case strings.HasPrefix(choice, mybotsChoiceRevokeGoPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceRevokeGoPrefix, edit, func(b ownedBot) botReply {
token, err := s.RevokeBotToken(ctx, state.UserID, b.user.ID)
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
return s.myBotsTokenScreen(ctx, &state, b,
fmt.Sprintf("Token for @%s changed, but I couldn't cut off sessions that are already logged in — tap Revoke again to be sure.", b.user.Username))
case err != nil:
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return internalReply()
}
return s.myBotsTokenScreenWithToken(&state, b, token,
fmt.Sprintf("Token for @%s has been revoked. The old one stops working immediately.", b.user.Username))
})
case strings.HasPrefix(choice, mybotsChoiceEditPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceEditPrefix, edit, func(b ownedBot) botReply {
return s.myBotsEditMenu(&state, b)
})
case strings.HasPrefix(choice, mybotsChoiceCfgPrefix) && !strings.HasPrefix(choice, mybotsChoiceCfgInline) &&
!strings.HasPrefix(choice, mybotsChoiceCfgGroups) && !strings.HasPrefix(choice, mybotsChoiceCfgPrivacy):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceCfgPrefix, edit, func(b ownedBot) botReply {
return s.myBotsSettingsScreen(&state, b, "")
})
case strings.HasPrefix(choice, mybotsChoiceCfgInline):
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgInline, edit, "inline")
case strings.HasPrefix(choice, mybotsChoiceCfgGroups):
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgGroups, edit, "groups")
case strings.HasPrefix(choice, mybotsChoiceCfgPrivacy):
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgPrivacy, edit, "privacy")
case strings.HasPrefix(choice, mybotsChoiceDeletePrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceDeletePrefix, edit, func(b ownedBot) botReply {
return s.myBotsDeleteConfirm(&state, b)
})
case strings.HasPrefix(choice, mybotsChoiceDeleteGoPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceDeleteGoPrefix, edit, func(b ownedBot) botReply {
if _, err := s.DeleteBot(ctx, b.user.ID); err != nil {
if errors.Is(err, domain.ErrBotSessionsNotRevoked) {
return botReply{Text: fmt.Sprintf("I couldn't safely delete @%s right now (its sessions are still active). Please try again in a moment.", b.user.Username)}
}
s.log.Error("botfather: delete bot", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return internalReply()
}
owned, err := s.ownedBots(ctx, state.UserID)
if err != nil || len(owned) == 0 {
return s.myBotsEmptyScreen(&state)
}
reply, ok := s.myBotsListScreen(ctx, &state, owned, 0)
if !ok {
return internalReply()
}
reply.Text = fmt.Sprintf("Deleted @%s.\n\n", b.user.Username) + reply.Text
return reply
})
case strings.HasPrefix(choice, mybotsChoiceBotpicPrefix):
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceBotpicPrefix, botFatherCmdSetBotpic)
case strings.HasPrefix(choice, mybotsChoiceSetNamePrefix):
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetNamePrefix, botFatherCmdSetName)
case strings.HasPrefix(choice, mybotsChoiceSetAboutPrefix):
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetAboutPrefix, botFatherCmdSetAbout)
case strings.HasPrefix(choice, mybotsChoiceSetDescPrefix):
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetDescPrefix, botFatherCmdSetDescription)
case strings.HasPrefix(choice, mybotsChoiceSetCmdsPrefix):
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetCmdsPrefix, botFatherCmdSetCommands)
default:
s.log.Warn("botfather: unknown mybots choice", zap.Int64("user_id", state.UserID), zap.String("choice", choice))
return mybotsAlert(mybotsExpiredButtonText)
}
}
// mybotsWithBot resolves the bot named by a choice against the caller's owned
// set, then hands it to render. A bot that is not (any longer) theirs falls back
// to the list.
func (s *Service) mybotsWithBot(
ctx context.Context,
state *domain.BotChatState,
choice, prefix string,
edit func(botReply) domain.BotCallbackAnswer,
render func(ownedBot) botReply,
) domain.BotCallbackAnswer {
botID, _ := strconv.ParseInt(strings.TrimPrefix(choice, prefix), 10, 64)
b, ok, err := s.myBotForUser(ctx, state.UserID, botID)
if err != nil {
return mybotsAlert(mybotsExpiredButtonText)
}
if !ok {
owned, listErr := s.ownedBots(ctx, state.UserID)
if listErr != nil || len(owned) == 0 {
return edit(s.myBotsEmptyScreen(state))
}
reply, built := s.myBotsListScreen(ctx, state, owned, 0)
if !built {
return mybotsAlert(mybotsExpiredButtonText)
}
reply.Text = mybotsGoneBotText + "\n\n" + reply.Text
return edit(reply)
}
return edit(render(b))
}
// mybotsToggleSetting flips one of the three per-bot settings and re-renders the
// settings screen with fresh labels.
func (s *Service) mybotsToggleSetting(
ctx context.Context,
state *domain.BotChatState,
choice, prefix string,
edit func(botReply) domain.BotCallbackAnswer,
which string,
) domain.BotCallbackAnswer {
return s.mybotsWithBot(ctx, state, choice, prefix, edit, func(b ownedBot) botReply {
var (
err error
verb string
)
switch which {
case "inline":
// "Just inline" per the menu spec: on = a minimal placeholder, off =
// disabled. /setinline still owns the placeholder text.
if b.profile.InlinePlaceholder == "" {
_, err = s.SetInlinePlaceholder(ctx, b.user.ID, "Search")
} else {
_, err = s.SetInlinePlaceholder(ctx, b.user.ID, "")
}
verb = "inline mode"
case "groups":
_, err = s.SetJoinGroups(ctx, b.user.ID, b.profile.Nochats /* was disallowed -> allow */)
verb = "group joining"
case "privacy":
// ChatHistory == privacy OFF. Toggle: enable privacy when it is off.
_, err = s.SetPrivacy(ctx, b.user.ID, b.profile.ChatHistory)
verb = "group privacy"
}
if err != nil {
s.log.Error("botfather: toggle "+verb, zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return s.myBotsSettingsScreen(state, b, "That didn't go through. Try again.")
}
fresh, ok, err := s.myBotForUser(ctx, state.UserID, b.user.ID)
if err != nil || !ok {
return s.myBotsSettingsScreen(state, b, "")
}
return s.myBotsSettingsScreen(state, fresh, "Updated "+verb+".")
})
}
// mybotsBeginValueInput hands off to the shared text-collection flow used by
// /setname & friends: it writes a botFatherStepValue state for that command and
// sends a fresh prompt message (a value cannot be collected on a button).
func (s *Service) mybotsBeginValueInput(ctx context.Context, state domain.BotChatState, choice, prefix, cmd string) domain.BotCallbackAnswer {
botID, _ := strconv.ParseInt(strings.TrimPrefix(choice, prefix), 10, 64)
b, ok, err := s.myBotForUser(ctx, state.UserID, botID)
if err != nil {
return mybotsAlert(mybotsExpiredButtonText)
}
if !ok {
return mybotsAlert(mybotsGoneBotText)
}
next := domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: state.UserID,
Command: cmd,
Step: botFatherStepValue,
Draft: map[string]string{
botFatherDraftBotID: strconv.FormatInt(b.user.ID, 10),
botFatherDraftBotUsername: b.user.Username,
},
}
if err := s.bots.UpsertBotChatState(ctx, next); err != nil {
s.log.Error("botfather: save mybots value state", zap.Int64("user_id", state.UserID), zap.Error(err))
return mybotsAlert(mybotsExpiredButtonText)
}
s.sendServiceBotReply(ctx, domain.BotFatherUserID, state.UserID, botReply{
Text: valuePrompt(cmd, b.user.Username) + "\n\nOr send /cancel.",
})
return domain.BotCallbackAnswer{}
}
// ---------------------------------------------------------------------------
// Screens
// ---------------------------------------------------------------------------
func (s *Service) myBotsEmptyScreen(state *domain.BotChatState) botReply {
// Nothing left to pick: drop the dialog so a stray text does not land on the
// "use the buttons" reminder.
state.Draft = map[string]string{}
return botReply{Text: mybotsNoBotsText}
}
// myBotsListScreen renders one page of the bot picker and records its buttons.
func (s *Service) myBotsListScreen(ctx context.Context, state *domain.BotChatState, owned []ownedBot, page int) (botReply, bool) {
pages := (len(owned) + mybotsPageSize - 1) / mybotsPageSize
if pages == 0 {
return s.myBotsEmptyScreen(state), true
}
if page < 0 {
page = 0
}
if page >= pages {
page = pages - 1
}
start := page * mybotsPageSize
end := start + mybotsPageSize
if end > len(owned) {
end = len(owned)
}
rows := make([][]mybotsOption, 0, mybotsPageSize+1)
for _, b := range owned[start:end] {
rows = append(rows, []mybotsOption{{
text: mybotsBotButtonLabel(b),
choice: mybotsChoiceBotPrefix + strconv.FormatInt(b.user.ID, 10),
}})
}
if pages > 1 {
var nav []mybotsOption
if page > 0 {
nav = append(nav, mybotsOption{text: " Prev", choice: mybotsChoiceListPrefix + strconv.Itoa(page-1)})
}
if page < pages-1 {
nav = append(nav, mybotsOption{text: "Next ", choice: mybotsChoiceListPrefix + strconv.Itoa(page+1)})
}
if len(nav) > 0 {
rows = append(rows, nav)
}
}
state.Step = mybotsStepMenu
state.Draft[mybotsDraftPage] = strconv.Itoa(page)
markup := s.mybotsKeyboard(state, rows)
text := "Choose a bot from the list below."
if pages > 1 {
text = fmt.Sprintf("Choose a bot from the list below.\n\nPage %d of %d.", page+1, pages)
}
_ = ctx
return botReply{Text: text, ReplyMarkup: markup}, markup != nil
}
func (s *Service) myBotsBotMenu(state *domain.BotChatState, b ownedBot) botReply {
page := mybotsDraftInt(*state, mybotsDraftPage)
rows := [][]mybotsOption{
{{text: "API Token", choice: mybotsChoiceTokenPrefix + botID64(b)}},
{{text: "Edit Bot", choice: mybotsChoiceEditPrefix + botID64(b)}},
{{text: "Bot Settings", choice: mybotsChoiceCfgPrefix + botID64(b)}},
{{text: "Delete Bot", choice: mybotsChoiceDeletePrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
{{text: " Back to bots", choice: mybotsChoiceListPrefix + strconv.FormatInt(page, 10)}},
}
markup := s.mybotsKeyboard(state, rows)
return botReply{
Text: fmt.Sprintf("@%s\n\nWhat do you want to do?", b.user.Username),
ReplyMarkup: markup,
}
}
func (s *Service) myBotsTokenScreen(ctx context.Context, state *domain.BotChatState, b ownedBot, lead string) botReply {
_ = ctx
profile, found, err := s.bots.GetBot(ctx, b.user.ID)
if err != nil || !found || profile.TokenSecret == "" {
if err != nil {
s.log.Error("botfather: get bot for token", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
}
return internalReply()
}
return s.myBotsTokenScreenWithToken(state, b, domain.FormatBotToken(b.user.ID, profile.TokenSecret), lead)
}
func (s *Service) myBotsTokenScreenWithToken(state *domain.BotChatState, b ownedBot, token, lead string) botReply {
head := lead
if head != "" {
head += "\n\n"
}
head += fmt.Sprintf("Token for @%s:\n", b.user.Username)
reply := tokenReply(head, token, "\n\nKeep it secret — anyone with this token controls the bot.")
rows := [][]mybotsOption{
{{text: "Revoke current token", choice: mybotsChoiceRevokePrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
}
reply.ReplyMarkup = s.mybotsKeyboard(state, rows)
return reply
}
func (s *Service) myBotsRevokeConfirm(state *domain.BotChatState, b ownedBot) botReply {
rows := [][]mybotsOption{
{{text: fmt.Sprintf("Yes, revoke @%s's token", b.user.Username), choice: mybotsChoiceRevokeGoPrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
{{text: " Keep it", choice: mybotsChoiceTokenPrefix + botID64(b)}},
}
return botReply{
Text: fmt.Sprintf("Revoke the current token for @%s?\n\nThe old token stops working immediately and a new one is generated. Anything using the old token will need updating.", b.user.Username),
ReplyMarkup: s.mybotsKeyboard(state, rows),
}
}
func (s *Service) myBotsEditMenu(state *domain.BotChatState, b ownedBot) botReply {
rows := [][]mybotsOption{
{{text: "Edit Name", choice: mybotsChoiceSetNamePrefix + botID64(b)}},
{{text: "Edit Description", choice: mybotsChoiceSetDescPrefix + botID64(b)}},
{{text: "Edit About", choice: mybotsChoiceSetAboutPrefix + botID64(b)}},
{{text: "Edit Botpic", choice: mybotsChoiceBotpicPrefix + botID64(b)}},
{{text: "Edit Commands", choice: mybotsChoiceSetCmdsPrefix + botID64(b)}},
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
}
return botReply{
Text: fmt.Sprintf("Editing @%s. Pick a field — I'll ask for the new value.", b.user.Username),
ReplyMarkup: s.mybotsKeyboard(state, rows),
}
}
func (s *Service) myBotsSettingsScreen(state *domain.BotChatState, b ownedBot, lead string) botReply {
inlineOn := b.profile.InlinePlaceholder != ""
groupsOn := !b.profile.Nochats
privacyOn := !b.profile.ChatHistory
rows := [][]mybotsOption{
{{text: "Inline Mode: " + onOff(inlineOn), choice: mybotsChoiceCfgInline + botID64(b)}},
{{text: "Allow Groups: " + onOff(groupsOn), choice: mybotsChoiceCfgGroups + botID64(b)}},
{{text: "Group Privacy: " + onOff(privacyOn), choice: mybotsChoiceCfgPrivacy + botID64(b)}},
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
}
body := fmt.Sprintf("Settings for @%s. Tap a row to flip it.\n\n"+
"• Inline Mode — %s\n"+
"• Allow Groups — %s (can the bot be added to groups)\n"+
"• Group Privacy — %s (on = only sees commands and replies in groups)",
b.user.Username, onOff(inlineOn), onOff(groupsOn), onOff(privacyOn))
if lead != "" {
body = lead + "\n\n" + body
}
return botReply{Text: body, ReplyMarkup: s.mybotsKeyboard(state, rows)}
}
func (s *Service) myBotsDeleteConfirm(state *domain.BotChatState, b ownedBot) botReply {
rows := [][]mybotsOption{
{{text: fmt.Sprintf("Yes, delete @%s", b.user.Username), choice: mybotsChoiceDeleteGoPrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
{{text: " Cancel", choice: mybotsChoiceBotPrefix + botID64(b)}},
}
return botReply{
Text: fmt.Sprintf("Delete @%s for good?\n\nThis cannot be undone. The bot's token stops working, its sessions are cut off, and the username is released.", b.user.Username),
ReplyMarkup: s.mybotsKeyboard(state, rows),
}
}
// ---------------------------------------------------------------------------
// Lookups
// ---------------------------------------------------------------------------
// myBotForUser returns the owned bot named by id. ok=false means it is not (any
// longer) one of this user's bots, which is also the authorisation check.
func (s *Service) myBotForUser(ctx context.Context, userID, botID int64) (ownedBot, bool, error) {
if botID <= 0 {
return ownedBot{}, false, nil
}
owned, err := s.ownedBots(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
return ownedBot{}, false, err
}
for _, b := range owned {
if b.user.ID == botID {
return b, true, nil
}
}
return ownedBot{}, false, nil
}
// ---------------------------------------------------------------------------
// Button tokens
// ---------------------------------------------------------------------------
// mybotsKeyboard renders one inline keyboard and records its buttons in the
// per-user token table. Same contract as verifyOptionKeyboard: callback data is
// only ever the prefix plus a random token that keys into *this* user's own
// BotChatState.Draft, tokens are minted per render and kept for
// mybotsTokenGenerations renders.
func (s *Service) mybotsKeyboard(state *domain.BotChatState, rows [][]mybotsOption) *domain.MessageReplyMarkup {
if state == nil || len(rows) == 0 {
return nil
}
if state.Draft == nil {
state.Draft = map[string]string{}
}
generation := mybotsDraftInt(*state, mybotsDraftGeneration) + 1
state.Draft[mybotsDraftGeneration] = strconv.FormatInt(generation, 10)
mybotsPruneOptions(state, generation)
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline}
for _, row := range rows {
buttons := make([]domain.MarkupButton, 0, len(row))
for _, option := range row {
if option.text == "" || option.choice == "" {
continue
}
token := s.mybotsOptionToken()
state.Draft[mybotsDraftOptionPrefix+token] = strconv.FormatInt(generation, 10) + "|" + option.choice
buttons = append(buttons, domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: option.text,
Style: option.style,
Data: []byte(mybotsCallbackDataPrefix + token),
})
}
if len(buttons) > 0 {
markup.Inline = append(markup.Inline, buttons)
}
}
if len(markup.Inline) == 0 {
return nil
}
return markup
}
func (s *Service) mybotsOptionToken() string {
var buf [mybotsOptionTokenBytes]byte
if _, err := rand.Read(buf[:]); err == nil {
return hex.EncodeToString(buf[:])
}
// A crypto/rand failure must not brick the menu. Unpredictability is defence
// in depth: a token is only ever resolved against the caller's own chat state,
// and the RPC edge already requires the data to appear in a keyboard of a
// message in the caller's own box.
return strconv.FormatInt(s.now().UnixNano()+s.replySeq.Add(1), 36)
}
// mybotsResolveOption maps callback data back onto the choice recorded for it.
// Anything not in this user's own table is refused.
func mybotsResolveOption(state domain.BotChatState, data []byte) (string, bool) {
raw := string(data)
if !strings.HasPrefix(raw, mybotsCallbackDataPrefix) {
return "", false
}
token := raw[len(mybotsCallbackDataPrefix):]
if token == "" || len(token) > mybotsOptionTokenMaxLen {
return "", false
}
value, found := state.Draft[mybotsDraftOptionPrefix+token]
if !found {
return "", false
}
_, choice, split := strings.Cut(value, "|")
if !split || choice == "" {
return "", false
}
return choice, true
}
func mybotsPruneOptions(state *domain.BotChatState, generation int64) {
oldest := generation - mybotsTokenGenerations + 1
for key, value := range state.Draft {
if !strings.HasPrefix(key, mybotsDraftOptionPrefix) {
continue
}
rawGen, _, _ := strings.Cut(value, "|")
gen, err := strconv.ParseInt(rawGen, 10, 64)
if err != nil || gen < oldest {
delete(state.Draft, key)
}
}
}
// ---------------------------------------------------------------------------
// State + small helpers
// ---------------------------------------------------------------------------
func (s *Service) saveMyBotsState(ctx context.Context, state domain.BotChatState) bool {
state.BotUserID = domain.BotFatherUserID
state.Command = mybotsCommand
if state.Step == "" {
state.Step = mybotsStepMenu
}
clone := domain.BotChatState{
BotUserID: state.BotUserID,
UserID: state.UserID,
Command: state.Command,
Step: state.Step,
Draft: make(map[string]string, len(state.Draft)),
}
for key, value := range state.Draft {
clone.Draft[key] = value
}
if err := s.bots.UpsertBotChatState(ctx, clone); err != nil {
s.log.Error("botfather: save mybots state", zap.Int64("user_id", state.UserID), zap.Error(err))
return false
}
return true
}
func mybotsDraftInt(state domain.BotChatState, key string) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(state.Draft[key]), 10, 64)
if err != nil {
return 0
}
return value
}
func mybotsAlert(text string) domain.BotCallbackAnswer {
if len([]rune(text)) > domain.MaxBotCallbackAnswerLen {
text = string([]rune(text)[:domain.MaxBotCallbackAnswerLen])
}
return domain.BotCallbackAnswer{Alert: true, Message: text}
}
func botID64(b ownedBot) string { return strconv.FormatInt(b.user.ID, 10) }
func onOff(on bool) string {
if on {
return "on"
}
return "off"
}
func mybotsBotButtonLabel(b ownedBot) string {
if b.user.Username != "" {
return "@" + b.user.Username
}
name := strings.TrimSpace(b.user.FirstName)
if name == "" {
name = "bot " + strconv.FormatInt(b.user.ID, 10)
}
return name
}

View file

@ -0,0 +1,123 @@
package bots
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeBotAvatar struct {
ownerType domain.PeerType
ownerID int64
sourcePhotoID int64
calls int
err error
}
func (f *fakeBotAvatar) SetAvatarFromExistingPhoto(_ context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, _ int) (domain.Photo, error) {
f.calls++
f.ownerType, f.ownerID, f.sourcePhotoID = ownerType, ownerID, sourcePhotoID
if f.err != nil {
return domain.Photo{}, f.err
}
return domain.Photo{ID: 90210}, nil
}
func botFatherPhotoMsg(userID, photoID int64) domain.Message {
return domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindPhoto,
Photo: &domain.Photo{ID: photoID},
},
}
}
func openBotpicPrompt(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User) {
t.Helper()
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@pic_mb_bot")
pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
_, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Botpic")
if !strings.Contains(prompt.Body, "profile picture") {
t.Fatalf("botpic prompt = %q", prompt.Body)
}
}
func TestMyBotsEditBotpicSetsPhoto(t *testing.T) {
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
messages := memory.NewMessageStore(memory.NewDialogStore())
avatar := &fakeBotAvatar{}
svc := NewService(users, bots, messages, WithBotAvatarStore(avatar))
owner := newOwner(t, users, "+2100")
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
openBotpicPrompt(t, svc, messages, owner)
// A non-photo message keeps the step and asks again.
if reply := sendToBotFather(t, svc, messages, owner, "here you go"); !strings.Contains(reply, "send a photo") {
t.Fatalf("text-instead-of-photo reply = %q", reply)
}
if avatar.calls != 0 {
t.Fatalf("avatar setter called for a non-photo message")
}
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
reply := botFatherUserReply(t, messages, owner.ID)
if !strings.Contains(reply.Body, "Profile picture updated") {
t.Fatalf("botpic success reply = %q", reply.Body)
}
if avatar.calls != 1 || avatar.ownerID != created.ID || avatar.sourcePhotoID != 7777 || avatar.ownerType != domain.PeerTypeUser {
t.Fatalf("avatar setter got (calls=%d owner=%d photo=%d type=%s)", avatar.calls, avatar.ownerID, avatar.sourcePhotoID, avatar.ownerType)
}
// The dialog is done: a stray message no longer lands on the botpic step.
if reply := sendToBotFather(t, svc, messages, owner, "anything"); strings.Contains(reply, "profile picture") {
t.Fatalf("botpic step still active after success: %q", reply)
}
}
func TestMyBotsEditBotpicRejectsBadImage(t *testing.T) {
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
messages := memory.NewMessageStore(memory.NewDialogStore())
avatar := &fakeBotAvatar{err: domain.ErrPhotoInvalid}
svc := NewService(users, bots, messages, WithBotAvatarStore(avatar))
owner := newOwner(t, users, "+2101")
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot"); err != nil {
t.Fatalf("create bot: %v", err)
}
openBotpicPrompt(t, svc, messages, owner)
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
reply := botFatherUserReply(t, messages, owner.ID)
if !strings.Contains(reply.Body, "couldn't use that image") {
t.Fatalf("bad image reply = %q", reply.Body)
}
// Step is kept so the user can send another photo.
if reply := sendToBotFather(t, svc, messages, owner, "x"); !strings.Contains(reply, "send a photo") {
t.Fatalf("after bad image, step not kept: %q", reply)
}
}
func TestMyBotsEditBotpicUnsupported(t *testing.T) {
svc, users, _, messages := newTestService(t) // no WithBotAvatarStore
owner := newOwner(t, users, "+2102")
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot"); err != nil {
t.Fatalf("create bot: %v", err)
}
openBotpicPrompt(t, svc, messages, owner)
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
reply := botFatherUserReply(t, messages, owner.ID)
if !strings.Contains(reply.Body, "isn't available on this server") {
t.Fatalf("unsupported reply = %q", reply.Body)
}
}

View file

@ -0,0 +1,387 @@
package bots
import (
"context"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func botFatherUserReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list user 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.Fatal("no @BotFather reply in the user's box")
}
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
}
return latest
}
// botFatherBotSideMessageID is the id of the bot's own copy of its latest reply
// to userID -- what the RPC edge resolves query.MessageID to before calling the
// responder, and what editServiceBotMessage addresses.
func botFatherBotSideMessageID(t *testing.T, messages *memory.MessageStore, userID int64) int {
t.Helper()
list, err := messages.ListByUser(context.Background(), domain.BotFatherUserID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Limit: 200,
})
if err != nil {
t.Fatalf("list bot history: %v", err)
}
latest := 0
for _, msg := range list.Messages {
if msg.From.ID == domain.BotFatherUserID && msg.ID > latest {
latest = msg.ID
}
}
if latest == 0 {
t.Fatal("no @BotFather copy of its own reply")
}
return latest
}
func mybotsButtonData(msg domain.Message, label string) ([]byte, bool) {
if msg.ReplyMarkup == nil {
return nil, false
}
for _, row := range msg.ReplyMarkup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
return append([]byte(nil), button.Data...), true
}
}
}
return nil, false
}
func mybotsHasButton(msg domain.Message, label string) bool {
_, ok := mybotsButtonData(msg, label)
return ok
}
// pressBotFather clicks the button whose text contains label on the user's latest
// @BotFather reply and returns the callback answer plus the user's new latest
// reply (which, for an in-place edit, is the same message updated).
func pressBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, label string) (domain.BotCallbackAnswer, domain.Message) {
t.Helper()
reply := botFatherUserReply(t, messages, userID)
data, ok := mybotsButtonData(reply, label)
if !ok {
t.Fatalf("button %q not in keyboard: %+v", label, reply.ReplyMarkup)
}
return pressBotFatherData(t, svc, messages, userID, data)
}
func pressBotFatherData(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, data []byte) (domain.BotCallbackAnswer, domain.Message) {
t.Helper()
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
ID: 1,
BotUserID: domain.BotFatherUserID,
UserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MessageID: botFatherBotSideMessageID(t, messages, userID),
Data: data,
})
if err != nil {
t.Fatalf("callback query: %v", err)
}
if !handled {
t.Fatal("callback reported unhandled for @BotFather")
}
return answer, botFatherUserReply(t, messages, userID)
}
func makeBotsP(t *testing.T, svc *Service, ownerID int64, prefix string, n int) {
t.Helper()
for i := 0; i < n; i++ {
if _, _, err := svc.CreateBot(context.Background(), ownerID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("%s%d_bot", prefix, i)); err != nil {
t.Fatalf("create bot %d: %v", i, err)
}
}
}
func makeBots(t *testing.T, svc *Service, ownerID int64, n int) {
t.Helper()
makeBotsP(t, svc, ownerID, "mb", n)
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
func TestMyBotsPickerPaginates(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+2001")
makeBots(t, svc, owner.ID, mybotsPageSize+3)
sendToBotFather(t, svc, messages, owner, "/mybots")
page1 := botFatherUserReply(t, messages, owner.ID)
if !strings.Contains(page1.Body, "Page 1 of 2") {
t.Fatalf("page 1 body = %q", page1.Body)
}
if mybotsHasButton(page1, " Prev") {
t.Fatal("first page must not offer Prev")
}
if !mybotsHasButton(page1, "Next ") {
t.Fatal("first page must offer Next")
}
if !mybotsHasButton(page1, "@mb0_bot") {
t.Fatalf("page 1 missing @mb0_bot: %+v", page1.ReplyMarkup)
}
_, page2 := pressBotFather(t, svc, messages, owner.ID, "Next ")
if !strings.Contains(page2.Body, "Page 2 of 2") {
t.Fatalf("page 2 body = %q", page2.Body)
}
if mybotsHasButton(page2, "Next ") {
t.Fatal("last page must not offer Next")
}
if !mybotsHasButton(page2, " Prev") {
t.Fatal("last page must offer Prev")
}
if !mybotsHasButton(page2, "@mb12_bot") {
t.Fatalf("page 2 missing @mb12_bot: %+v", page2.ReplyMarkup)
}
// The pager edits one message in place rather than piling up new ones.
if page2.ID != page1.ID {
t.Fatalf("pager sent a new message (%d -> %d), want in-place edit", page1.ID, page2.ID)
}
}
func TestMyBotsBotMenuAndBack(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+2002")
makeBots(t, svc, owner.ID, 2)
sendToBotFather(t, svc, messages, owner, "/mybots")
_, menu := pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
if !strings.Contains(menu.Body, "@mb0_bot") {
t.Fatalf("bot menu body = %q", menu.Body)
}
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
if !mybotsHasButton(menu, want) {
t.Fatalf("bot menu missing %q: %+v", want, menu.ReplyMarkup)
}
}
_, back := pressBotFather(t, svc, messages, owner.ID, "Back to bots")
if !strings.Contains(back.Body, "Choose a bot") {
t.Fatalf("back reply = %q", back.Body)
}
}
func TestMyBotsTokenAndRevoke(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+2003")
created, token, err := svc.CreateBot(context.Background(), owner.ID, "Tok Bot", "tok_mb_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@tok_mb_bot")
_, tokenScreen := pressBotFather(t, svc, messages, owner.ID, "API Token")
if !strings.Contains(tokenScreen.Body, token) {
t.Fatalf("token screen %q missing token %q", tokenScreen.Body, token)
}
pressBotFather(t, svc, messages, owner.ID, "Revoke current token")
_, revoked := pressBotFather(t, svc, messages, owner.ID, "Yes, revoke")
if strings.Contains(revoked.Body, token) {
t.Fatalf("revoke screen still shows the old token: %q", revoked.Body)
}
if !strings.Contains(revoked.Body, "has been revoked") {
t.Fatalf("revoke screen = %q", revoked.Body)
}
profile, _, err := bots.GetBot(context.Background(), created.ID)
if err != nil {
t.Fatalf("get bot: %v", err)
}
if domain.FormatBotToken(created.ID, profile.TokenSecret) == token {
t.Fatal("token was not rotated")
}
}
func TestMyBotsSettingsToggles(t *testing.T) {
svc, users, bots, messages := newTestService(t)
owner := newOwner(t, users, "+2004")
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Cfg Bot", "cfg_mb_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
ctx := context.Background()
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@cfg_mb_bot")
_, settings := pressBotFather(t, svc, messages, owner.ID, "Bot Settings")
if !mybotsHasButton(settings, "Inline Mode: off") {
t.Fatalf("settings screen: %+v", settings.ReplyMarkup)
}
_, afterInline := pressBotFather(t, svc, messages, owner.ID, "Inline Mode:")
if !mybotsHasButton(afterInline, "Inline Mode: on") {
t.Fatalf("inline not toggled on: %+v", afterInline.ReplyMarkup)
}
profile, _, _ := bots.GetBot(ctx, created.ID)
if profile.InlinePlaceholder == "" {
t.Fatal("inline placeholder not set after toggle")
}
privacyBefore := mybotsHasButton(afterInline, "Group Privacy: on")
_, afterPrivacy := pressBotFather(t, svc, messages, owner.ID, "Group Privacy:")
if mybotsHasButton(afterPrivacy, "Group Privacy: on") == privacyBefore {
t.Fatalf("privacy not toggled: %+v", afterPrivacy.ReplyMarkup)
}
privProfile, _, _ := bots.GetBot(ctx, created.ID)
if (!privProfile.ChatHistory) == privacyBefore {
t.Fatal("privacy flag not flipped in the store")
}
_, afterGroups := pressBotFather(t, svc, messages, owner.ID, "Allow Groups:")
if !mybotsHasButton(afterGroups, "Allow Groups: off") {
t.Fatalf("groups not toggled off: %+v", afterGroups.ReplyMarkup)
}
profile, _, _ = bots.GetBot(ctx, created.ID)
if !profile.Nochats {
t.Fatal("nochats not set after toggling groups off")
}
}
func TestMyBotsEditNameHandsOffToValueInput(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+2005")
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Old Name", "edit_mb_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@edit_mb_bot")
pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Name")
if answer.Message != "" {
t.Fatalf("edit-name click alerted: %q", answer.Message)
}
if !strings.Contains(prompt.Body, "new name") {
t.Fatalf("edit-name prompt = %q", prompt.Body)
}
reply := sendToBotFather(t, svc, messages, owner, "Shiny New Name")
if !strings.Contains(reply, "Name updated") {
t.Fatalf("set name reply = %q", reply)
}
name, _, _, err := svc.GetBotInfo(context.Background(), created.ID)
if err != nil {
t.Fatalf("get bot info: %v", err)
}
if name != "Shiny New Name" {
t.Fatalf("bot name = %q, want updated", name)
}
}
// deletableBotStore adds a real DeleteBotAccount (memory.BotStore has none) so
// the /mybots delete flow can be exercised end to end.
type deletableBotStore struct {
*memory.BotStore
deleted map[int64]bool
}
func (d *deletableBotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
d.deleted[botUserID] = true
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
}
func (d *deletableBotStore) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
profiles, err := d.BotStore.ListBotsByOwner(ctx, ownerUserID)
if err != nil {
return nil, err
}
out := profiles[:0]
for _, p := range profiles {
if !d.deleted[p.BotUserID] {
out = append(out, p)
}
}
return out, nil
}
func TestMyBotsDeleteBot(t *testing.T) {
users := memory.NewUserStore()
store := &deletableBotStore{BotStore: memory.NewBotStore(users), deleted: map[int64]bool{}}
messages := memory.NewMessageStore(memory.NewDialogStore())
svc := NewService(users, store, messages)
svc.SetRouterHooks(&captureRevoker{})
owner := newOwner(t, users, "+2006")
makeBots(t, svc, owner.ID, 2)
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
pressBotFather(t, svc, messages, owner.ID, "Delete Bot")
_, afterDelete := pressBotFather(t, svc, messages, owner.ID, "Yes, delete")
if !strings.Contains(afterDelete.Body, "Deleted @mb0_bot") {
t.Fatalf("after delete body = %q", afterDelete.Body)
}
if mybotsHasButton(afterDelete, "@mb0_bot") {
t.Fatal("deleted bot still listed")
}
if !mybotsHasButton(afterDelete, "@mb1_bot") {
t.Fatalf("surviving bot dropped: %+v", afterDelete.ReplyMarkup)
}
}
func TestMyBotsForeignAndStaleTokensRefused(t *testing.T) {
svc, users, _, messages := newTestService(t)
alice := newOwner(t, users, "+2007")
bob := newOwner(t, users, "+2008")
makeBotsP(t, svc, alice.ID, "alice", 1)
makeBotsP(t, svc, bob.ID, "bob", 1)
// Alice opens her menu; Bob replays one of her tokens against his own dialog.
sendToBotFather(t, svc, messages, alice, "/mybots")
aliceMenu := botFatherUserReply(t, messages, alice.ID)
var aliceToken []byte
for _, row := range aliceMenu.ReplyMarkup.Inline {
for _, b := range row {
if len(b.Data) > 0 {
aliceToken = append([]byte(nil), b.Data...)
}
}
}
sendToBotFather(t, svc, messages, bob, "/mybots")
answer, _ := pressBotFatherData(t, svc, messages, bob.ID, aliceToken)
if !answer.Alert || answer.Message == "" {
t.Fatalf("replayed foreign token answer = %+v, want expired-button alert", answer)
}
// A stale generation of Alice's own buttons is refused too.
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 2
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 3
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 4 -> gen 1 pruned
answer, _ = pressBotFatherData(t, svc, messages, alice.ID, aliceToken)
if !answer.Alert {
t.Fatalf("stale token answer = %+v, want expired-button alert", answer)
}
}

View file

@ -6,6 +6,7 @@ package bots
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/url"
"strings"
@ -76,6 +77,13 @@ type verificationApplications interface {
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
}
// botAvatarStore renders an already-stored photo (one a user sent to @BotFather)
// into a bot's profile photo. app/files.Service satisfies it. It is a narrow port
// because a service bot must not reach into the file layer for anything else.
type botAvatarStore interface {
SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error)
}
// The third-party verification ports live in verifierbot.go
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
// only way to reach the feature, and are kept next to the dialog that uses them.
@ -120,6 +128,7 @@ type Service struct {
verifierTargets verifierBotTargets
gifCatalog gifCatalogSource
telegramLogin *telegramloginapp.Service
botAvatar botAvatarStore
hooks RouterHooks
textDrafts TextDraftPusher
userCache store.UserCache
@ -176,6 +185,17 @@ func WithBlockChecker(c blockChecker) Option {
}
}
// WithBotAvatarStore injects the ability to set a bot's profile photo from a
// photo a user sent to @BotFather ("Edit Botpic"). Absent it, that action reports
// that it is unavailable on this server.
func WithBotAvatarStore(a botAvatarStore) Option {
return func(s *Service) {
if a != nil {
s.botAvatar = a
}
}
}
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
// username 预检,避免 bot 与 public channel 产生同名可见入口。
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
@ -902,6 +922,30 @@ func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool)
return version, nil
}
// ErrBotUserpicUnsupported reports that this server has no file layer wired for
// setting a bot's profile photo (WithBotAvatarStore was not supplied).
var ErrBotUserpicUnsupported = errors.New("bot profile photo is not supported by this server")
// SetBotUserpic makes an already-stored photo (one a user sent to @BotFather) the
// bot's current profile photo. sourcePhotoID is a message photo id; the file
// layer re-renders it into the avatar size set.
func (s *Service) SetBotUserpic(ctx context.Context, botUserID, sourcePhotoID int64) error {
if s == nil || botUserID == 0 {
return domain.ErrBotNotFound
}
if s.botAvatar == nil {
return ErrBotUserpicUnsupported
}
if sourcePhotoID <= 0 {
return domain.ErrPhotoInvalid
}
if _, err := s.botAvatar.SetAvatarFromExistingPhoto(ctx, domain.PeerTypeUser, botUserID, sourcePhotoID, int(s.now().Unix())); err != nil {
return err
}
s.invalidateBotReadCaches(ctx, botUserID)
return 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) {

View file

@ -31,10 +31,18 @@ func newOwner(t *testing.T, users *memory.UserStore, phone string) domain.User {
// sendToBotFather 同步驱动 responder绕过 OnPrivateMessage 的 goroutine 派发以
// 保证单测确定性;异步派发由 mtprotoedge bot e2e 覆盖),返回 BotFather 最新回复文本。
func botFatherMsg(userID int64, text string) domain.Message {
return domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
Body: text,
}
}
func sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
t.Helper()
ctx := context.Background()
svc.respondAsBotFather(owner.ID, text)
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, text))
list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
@ -332,8 +340,8 @@ func TestBotFatherMyBotsAndLimit(t *testing.T) {
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 reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "Choose a bot") {
t.Fatalf("/mybots reply = %q, want bot picker", reply)
}
if _, _, err := svc.CreateBot(ctx, owner.ID, "One Too Many", "toomany_bot"); err != domain.ErrBotsTooMany {
t.Fatalf("create over limit err = %v, want ErrBotsTooMany", err)
@ -384,7 +392,7 @@ func TestBotFatherReplyRespectsBlock(t *testing.T) {
owner := newOwner(t, users, "+1099")
ctx := context.Background()
svc.respondAsBotFather(owner.ID, "/help")
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, "/help"))
// IsBlocked 参数语义owner(userID) 是否 block 了 BotFather(blockedUserID)。
if blocker.gotUser != owner.ID || blocker.gotPeer != domain.BotFatherUserID {
@ -408,7 +416,7 @@ func TestBotFatherReplyRespectsBlock(t *testing.T) {
// 未 block回复正常投递。
blocker.blocked = false
other := newOwner(t, users, "+1098")
svc.respondAsBotFather(other.ID, "/help")
svc.respondAsBotFather(other.ID, botFatherMsg(other.ID, "/help"))
otherList, err := messages.ListByUser(ctx, other.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},

View file

@ -355,6 +355,10 @@ func (s *Service) OnCallbackQuery(ctx context.Context, query domain.BotCallbackQ
// (verifierbot.go).
return s.onVerifierCallback(ctx, query)
}
if query.BotUserID == domain.BotFatherUserID {
// @BotFather's button-driven /mybots menu (mybots.go).
return s.onBotFatherCallback(ctx, query)
}
if query.BotUserID != domain.VerifyBotUserID {
// The other built-in bots never attach an inline keyboard, so there is
// nothing to route. An empty answer still beats hanging the click for the

View file

@ -869,12 +869,13 @@ func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A built-in bot with no keyboards is claimed but answered empty, so the click
// cannot hang for the whole callback timeout.
// @BotFather owns the /mybots menu now: a click with no active dialog is
// claimed and answered with the expired-button alert, so it cannot hang for
// the whole callback timeout.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("bf:x"),
})
if !handled || err != nil || answer.Message != "" {
if !handled || err != nil || !answer.Alert || answer.Message == "" {
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
}
}

View file

@ -0,0 +1,78 @@
package files
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// maxBotAvatarSourceBytes bounds the source image @BotFather turns into a bot's
// profile photo. A profile photo a user sends in a chat is a server-rendered
// message-size JPEG, comfortably under this.
const maxBotAvatarSourceBytes = 12 << 20
// SetAvatarFromExistingPhoto renders an already-stored photo (typically one a
// user just sent to a service bot) into the s/a/c avatar size set and makes it
// the current profile photo of ownerType/ownerID.
//
// It is the path @BotFather's "Edit Botpic" uses: photos.uploadProfilePhoto#bot
// is not accepted, and the source is a message photo rather than a fresh upload,
// so the ordinary UploadProfilePhoto path does not apply.
func (s *Service) SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error) {
if ownerID <= 0 || sourcePhotoID <= 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
source, found, err := s.media.GetPhoto(ctx, sourcePhotoID)
if err != nil {
return domain.Photo{}, err
}
if !found {
return domain.Photo{}, domain.ErrPhotoInvalid
}
data, ok := s.photoSourceBytes(ctx, source)
if !ok || !s.ValidateAvatarUpload(data) {
return domain.Photo{}, domain.ErrPhotoInvalid
}
if date == 0 {
date = int(time.Now().Unix())
}
photo, err := s.createAvatarPhoto(ctx, data, ownerID)
if err != nil {
return domain.Photo{}, err
}
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photo.ID, date); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
// photoSourceBytes reads the original image bytes behind a stored photo. Every
// static size of a photo written by putPhotoStaticSizes points at the same
// stored object, so any one size yields the full image.
func (s *Service) photoSourceBytes(ctx context.Context, photo domain.Photo) ([]byte, bool) {
for _, size := range photo.Sizes {
if size.Type == "" {
continue
}
key := fmt.Sprintf("photo:%d:%s", photo.ID, size.Type)
blob, found, err := s.media.GetFileBlob(ctx, key)
if err != nil || !found || blob.Size <= 0 || blob.Size > maxBotAvatarSourceBytes {
continue
}
backend, err := s.backendFor(blob.Backend)
if err != nil {
continue
}
body, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil || total != blob.Size || int64(len(body)) != blob.Size {
s.log.Warn("read bot avatar source blob failed", zap.String("location_key", key), zap.Error(err))
continue
}
return body, true
}
return nil, false
}