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
1ebf8ddb5a
commit
11142f74c5
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"
|
||||
|
|
@ -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==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 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
||||
|
|
@ -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 并设置 joingroups(join=true)或 privacy(join=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
729
internal/app/bots/mybots.go
Normal 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.BotChatState(Command="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
|
||||
}
|
||||
123
internal/app/bots/mybots_botpic_test.go
Normal file
123
internal/app/bots/mybots_botpic_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
387
internal/app/bots/mybots_test.go
Normal file
387
internal/app/bots/mybots_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
internal/app/files/bot_avatar.go
Normal file
78
internal/app/files/bot_avatar.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue