Interactive /mybots menu for @BotFather

Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
This commit is contained in:
Astra 2026-09-08 13:31:05 +01:00
parent 196f90f9b1
commit f474a360d7
10 changed files with 1467 additions and 26 deletions

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

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