From f474a360d791446626a67a691a6313d64f12d171 Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 8 Sep 2026 13:31:05 +0100 Subject: [PATCH] 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. --- cmd/telesrv/main.go | 1 + internal/app/bots/botfather.go | 100 +++- internal/app/bots/mybots.go | 729 ++++++++++++++++++++++++ internal/app/bots/mybots_botpic_test.go | 123 ++++ internal/app/bots/mybots_test.go | 387 +++++++++++++ internal/app/bots/service.go | 44 ++ internal/app/bots/service_test.go | 18 +- internal/app/bots/verifybot.go | 4 + internal/app/bots/verifybot_test.go | 9 +- internal/app/files/bot_avatar.go | 78 +++ 10 files changed, 1467 insertions(+), 26 deletions(-) create mode 100644 internal/app/bots/mybots.go create mode 100644 internal/app/bots/mybots_botpic_test.go create mode 100644 internal/app/bots/mybots_test.go create mode 100644 internal/app/files/bot_avatar.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 49c6e1bc..9c0af9bb 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1241,6 +1241,7 @@ func run(logger *zap.Logger) error { botsapp.WithUserCache(userCache), botsapp.WithStickerSetCreator(filesService), botsapp.WithGifCatalogSource(filesService), + botsapp.WithBotAvatarStore(filesService), botsapp.WithUserStickerSets(accountService), botsapp.WithTelegramLogin(telegramLoginService), botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow), diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 8a545f08..22d6a78a 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -26,6 +26,7 @@ const ( botFatherCmdToken = "token" botFatherCmdRevoke = "revoke" botFatherCmdSetName = "setname" + botFatherCmdSetBotpic = "setbotpic" botFatherCmdSetDescription = "setdescription" botFatherCmdSetAbout = "setabouttext" botFatherCmdSetCommands = "setcommands" @@ -118,7 +119,7 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom } switch botUserID { case domain.BotFatherUserID: - go s.respondAsBotFather(userID, msg.Body) + go s.respondAsBotFather(userID, msg) case domain.StickersBotUserID: go s.respondAsStickers(userID, msg) case domain.ChatBotUserID: @@ -133,14 +134,14 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom // respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。 // 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序, // 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。 -func (s *Service) respondAsBotFather(userID int64, body string) { +func (s *Service) respondAsBotFather(userID int64, msg domain.Message) { mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID) mu.Lock() defer mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - reply := s.handleBotFather(ctx, userID, body) + reply := s.handleBotFather(ctx, userID, msg) s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply) } @@ -198,6 +199,45 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user return res, true } +// editServiceBotMessage 就地改写一条内置 bot 自己发出的消息(正文 + inline keyboard)。 +// 用于按钮式菜单(@BotFather /mybots)在点击后原地翻页/下钻,而不是刷屏新消息。 +// OwnerUserID 取 bot 自身:store 的 authorEdit 判定要求 sender==from==owner,bot 发出的 +// 那一份 outbox 拷贝正好满足。ErrMessageNotModified 视为成功(点了同一个按钮两次)。 +func (s *Service) editServiceBotMessage(ctx context.Context, botUserID, userID int64, messageID int, reply botReply) bool { + if s == nil || s.messages == nil || messageID <= 0 || reply.Text == "" { + return false + } + markup := reply.ReplyMarkup + if err := domain.ValidateReplyMarkup(markup); err != nil { + s.log.Error("service bot: invalid reply markup for edit", + zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err)) + markup = nil + } + if markup.IsZero() { + markup = nil + } + _, err := s.messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: botUserID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + ID: messageID, + Message: reply.Text, + Entities: serviceBotReplyEntities(reply.Text, reply.Entities), + EditDate: int(s.now().Unix()), + HideEdited: true, + SetReplyMarkup: true, + ReplyMarkup: markup, + }) + if errors.Is(err, domain.ErrMessageNotModified) { + return true + } + if err != nil { + s.log.Error("service bot: edit reply", + zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err)) + return false + } + return true +} + // botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。 // 所有服务 bot 回复按各自 sender 命名空间唯一——用 // crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。 @@ -226,8 +266,8 @@ var botFatherGlobalCommands = map[string]bool{ botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true, } -func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply { - text = strings.TrimSpace(text) +func (s *Service) handleBotFather(ctx context.Context, userID int64, msg domain.Message) botReply { + text := strings.TrimSpace(msg.Body) state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID) if err != nil { s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err)) @@ -244,6 +284,11 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string if text == "" { // 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示, // 无状态保持沉默(避免对任意非文本消息刷屏)。 + if found && state.Step == botFatherStepValue && msg.Media != nil { + // A photo for /setbotpic arrives with no caption: hand the message to + // the value step instead of replaying the prompt. + return s.handleSetValue(ctx, state, msg) + } if !found { return botReply{} } @@ -260,7 +305,9 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string case state.Step == botFatherStepChoose: return s.handleChooseBot(ctx, state, text) case state.Step == botFatherStepValue: - return s.handleSetValue(ctx, state, text) + return s.handleSetValue(ctx, state, msg) + case state.Command == mybotsCommand: + return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."} default: // 不可达的脏状态:清掉重来,避免用户被卡死。 _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID) @@ -318,6 +365,8 @@ func (s *Service) stepPrompt(state domain.BotChatState) botReply { return botReply{Text: "Please send the username of one of your bots, or /cancel."} case state.Step == botFatherStepValue: return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])} + case state.Command == mybotsCommand: + return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."} default: return botReply{Text: "Send /help for a list of commands."} } @@ -367,15 +416,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd } return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."} case "mybots": - usernames, err := s.ownedBotUsernames(ctx, userID) - if err != nil { - s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err)) - return internalReply() - } - if len(usernames) == 0 { - return botReply{Text: "You don't have any bots yet. Use /newbot to create one."} - } - return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")} + return s.startMyBots(ctx, userID) case botFatherCmdToken, botFatherCmdRevoke, botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout, botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo, @@ -395,6 +436,8 @@ func valuePrompt(cmd, username string) string { switch cmd { case botFatherCmdSetName: return fmt.Sprintf("OK. Send me the new name for @%s.", username) + case botFatherCmdSetBotpic: + return fmt.Sprintf("OK. Send me the new profile picture for @%s. Send it as a photo.", username) case botFatherCmdSetDescription: return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username) case botFatherCmdSetAbout: @@ -581,8 +624,10 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState } } -// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。 -func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply { +// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。除 /setbotpic 收一张 +// 照片外都是纯文本;照片经 msg.Media 传入。 +func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply { + text := strings.TrimSpace(msg.Body) botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64) username := state.Draft[botFatherDraftBotUsername] if botID == 0 { @@ -622,6 +667,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, reply, err = s.applyToggle(ctx, botID, text, true) case botFatherCmdSetPrivacy: reply, err = s.applyToggle(ctx, botID, text, false) + case botFatherCmdSetBotpic: + reply, err = s.applySetBotpic(ctx, botID, username, msg) case botFatherCmdSetLogin: return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text) default: @@ -960,6 +1007,25 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid } +// applySetBotpic 把用户发给 BotFather 的一张照片设为 bot 头像。只收 photo(不收 +// 文件/贴纸);文件层重渲染成头像尺寸集。 +func (s *Service) applySetBotpic(ctx context.Context, botID int64, username string, msg domain.Message) (botReply, error) { + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPhoto || msg.Media.Photo == nil || msg.Media.Photo.ID == 0 { + return botReply{Text: "Please send a photo (as a photo, not a file), or /cancel."}, domain.ErrPhotoInvalid + } + err := s.SetBotUserpic(ctx, botID, msg.Media.Photo.ID) + switch { + case errors.Is(err, ErrBotUserpicUnsupported): + return botReply{Text: "Setting a profile picture isn't available on this server."}, err + case errors.Is(err, domain.ErrPhotoInvalid): + return botReply{Text: "Sorry, I couldn't use that image. Send a JPEG or PNG photo, or /cancel."}, err + case err != nil: + s.log.Error("botfather: set botpic", zap.Int64("bot_user_id", botID), zap.Error(err)) + return botReply{}, err + } + return botReply{Text: fmt.Sprintf("Success! Profile picture updated for @%s.", username)}, nil +} + // applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。 func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) { var on bool diff --git a/internal/app/bots/mybots.go b/internal/app/bots/mybots.go new file mode 100644 index 00000000..bad103aa --- /dev/null +++ b/internal/app/bots/mybots.go @@ -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: + mybotsChoiceBotPrefix = "bot:" // bot: -> per-bot menu + mybotsChoiceTokenPrefix = "tok:" // tok: -> API token screen + mybotsChoiceRevokePrefix = "rvk:" // rvk: -> revoke confirm + mybotsChoiceRevokeGoPrefix = "rvkgo:" // rvkgo: -> do revoke + mybotsChoiceEditPrefix = "edit:" // edit: -> Edit Bot menu + mybotsChoiceSetNamePrefix = "setname:" // setname: + mybotsChoiceSetAboutPrefix = "setabout:" // setabout: + mybotsChoiceSetDescPrefix = "setdesc:" // setdesc: + mybotsChoiceSetCmdsPrefix = "setcmds:" // setcmds: + mybotsChoiceBotpicPrefix = "botpic:" // botpic: -> phase 2, alert for now + mybotsChoiceCfgPrefix = "cfg:" // cfg: -> Bot Settings screen + mybotsChoiceCfgInline = "cfginl:" // cfginl: -> toggle inline mode + mybotsChoiceCfgGroups = "cfggrp:" // cfggrp: -> toggle allow groups + mybotsChoiceCfgPrivacy = "cfgprv:" // cfgprv: -> toggle group privacy + mybotsChoiceDeletePrefix = "del:" // del: -> delete confirm + mybotsChoiceDeleteGoPrefix = "delgo:" // delgo: -> 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 +} diff --git a/internal/app/bots/mybots_botpic_test.go b/internal/app/bots/mybots_botpic_test.go new file mode 100644 index 00000000..60570246 --- /dev/null +++ b/internal/app/bots/mybots_botpic_test.go @@ -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) + } +} diff --git a/internal/app/bots/mybots_test.go b/internal/app/bots/mybots_test.go new file mode 100644 index 00000000..476f0587 --- /dev/null +++ b/internal/app/bots/mybots_test.go @@ -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) + } +} diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index f91ce9a0..0a7494be 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -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) { diff --git a/internal/app/bots/service_test.go b/internal/app/bots/service_test.go index c68bfc6f..441cae8b 100644 --- a/internal/app/bots/service_test.go +++ b/internal/app/bots/service_test.go @@ -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}, diff --git a/internal/app/bots/verifybot.go b/internal/app/bots/verifybot.go index d0564604..86c09595 100644 --- a/internal/app/bots/verifybot.go +++ b/internal/app/bots/verifybot.go @@ -359,6 +359,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 diff --git a/internal/app/bots/verifybot_test.go b/internal/app/bots/verifybot_test.go index fdefa5c1..a17901be 100644 --- a/internal/app/bots/verifybot_test.go +++ b/internal/app/bots/verifybot_test.go @@ -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) } } diff --git a/internal/app/files/bot_avatar.go b/internal/app/files/bot_avatar.go new file mode 100644 index 00000000..bf87b31f --- /dev/null +++ b/internal/app/files/bot_avatar.go @@ -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 +}