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:" // mybotsDraftReturn marks a botFatherStepValue state that was opened from the // /mybots menu, so a successful field edit lands back on the Edit Bot menu // (a fresh message with working buttons) instead of just ending the dialog. mybotsDraftReturn = "mb_ret" // 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(ctx, &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, mybotsDraftReturn: "1", mybotsDraftPage: state.Draft[mybotsDraftPage], }, } 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(ctx context.Context, state *domain.BotChatState, b ownedBot) botReply { name, about, description, err := s.GetBotInfo(ctx, b.user.ID) if err != nil { s.log.Error("botfather: get bot info for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err)) return internalReply() } commands, err := s.GetBotCommands(ctx, b.user.ID) if err != nil { s.log.Error("botfather: get bot commands for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err)) return internalReply() } page := mybotsDraftInt(*state, mybotsDraftPage) rows := [][]mybotsOption{ {{text: "Edit Name", choice: mybotsChoiceSetNamePrefix + botID64(b)}}, {{text: "Edit About", choice: mybotsChoiceSetAboutPrefix + botID64(b)}}, {{text: "Edit Description", choice: mybotsChoiceSetDescPrefix + botID64(b)}}, {{text: "Edit Botpic", choice: mybotsChoiceBotpicPrefix + botID64(b)}}, {{text: "Edit Commands", choice: mybotsChoiceSetCmdsPrefix + botID64(b)}}, { {text: "‹ Back to bot", choice: mybotsChoiceBotPrefix + botID64(b)}, {text: "‹‹ Bots list", choice: mybotsChoiceListPrefix + strconv.FormatInt(page, 10)}, }, } return botReply{ Text: myBotsEditSummary(b.user.Username, name, about, description, commands, s.botHasAvatar(ctx, b.user.ID)), ReplyMarkup: s.mybotsKeyboard(state, rows), } } // myBotsEditSummary renders the "Edit @bot info" screen: the current value of // every editable field, mirroring what BotFather shows. func myBotsEditSummary(username, name, about, description string, commands []domain.BotCommand, hasBotpic bool) string { orNone := func(v string) string { v = strings.ReplaceAll(strings.TrimSpace(v), "\n", " ") if v == "" { return "🚫" } if r := []rune(v); len(r) > 120 { v = string(r[:117]) + "..." } return v } cmds := "no commands yet" if n := len(commands); n == 1 { cmds = "1 command" } else if n > 1 { cmds = fmt.Sprintf("%d commands", n) } botpic := "🚫 no botpic" if hasBotpic { botpic = "🖼 has a botpic" } return fmt.Sprintf( "Edit @%s info.\n\nName: %s\nAbout: %s\nDescription: %s\nDescription picture: 🚫 no description picture\nBotpic: %s\nCommands: %s\nPrivacy Policy: 🚫", username, orNone(name), orNone(about), orNone(description), botpic, cmds, ) } // botHasAvatar reports whether the bot currently has a profile photo. Without a // file layer wired it answers false. func (s *Service) botHasAvatar(ctx context.Context, botUserID int64) bool { if s.botAvatar == nil { return false } ok, err := s.botAvatar.PeerHasAvatar(ctx, domain.PeerTypeUser, botUserID) if err != nil { s.log.Warn("botfather: check bot avatar", zap.Int64("bot_user_id", botUserID), zap.Error(err)) return false } return ok } // myBotsReturnToEditMenu rebuilds a fresh /mybots dialog on the Edit Bot menu // after a field was edited through the shared value-input flow, so the follow-up // message carries working "Back to bot" / "Bots list" buttons instead of the // dialog just ending. func (s *Service) myBotsReturnToEditMenu(ctx context.Context, userID, botID int64, page, lead string) botReply { b, ok, err := s.myBotForUser(ctx, userID, botID) if err != nil || !ok { s.clearState(ctx, userID) return botReply{Text: strings.TrimSpace(lead)} } st := domain.BotChatState{ BotUserID: domain.BotFatherUserID, UserID: userID, Command: mybotsCommand, Step: mybotsStepMenu, Draft: map[string]string{}, } if p := strings.TrimSpace(page); p != "" { st.Draft[mybotsDraftPage] = p } menu := s.myBotsEditMenu(ctx, &st, b) if menu.ReplyMarkup == nil || !s.saveMyBotsState(ctx, st) { s.clearState(ctx, userID) return botReply{Text: strings.TrimSpace(lead)} } if lead = strings.TrimSpace(lead); lead != "" { menu.Text = lead + "\n\n" + menu.Text } return menu } 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 }