owpengram-server/internal/app/bots/mybots.go
Astra 5afafc60c4 /mybots: Edit Bot summary screen, return-to-menu, clickable @mentions
- Edit Bot now shows the current value of every field (Name/About/
  Description/Botpic/Commands) like BotFather, with real botpic status
  via a new PeerHasAvatar port method.
- After editing a field the dialog lands back on a fresh Edit Bot menu
  (working "Back to bot" / "Bots list" buttons) instead of ending, so a
  follow-up button press no longer reports the button as expired.
- Service-bot messages now render @username as a tappable mention entity.
2026-09-14 11:53:43 +01:00

823 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package bots
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// @BotFather 的按钮式 /mybots列出 owner 的 bot每行一个 + 上一页/下一页),点进
// 单个 bot 后是 API Token / Edit Bot / Bot Settings / Delete Bot 四个入口。除了
// "Edit Bot" 下的文本字段(改名/简介/描述/命令列表,交回既有 handleSetValue 收值
// 流程)之外,全部翻页与下钻都在同一条消息上就地 EditMessage不刷屏。
//
// 会话状态 domain.BotChatStateCommand="mybots"),与其余 @BotFather 流程一样在
// per-user serviceBotReplyLock 下 Get→改→Upsert回调按用户串行、保序。
//
// 安全:回调 data 只带一个随机 token映射到 *该用户自己* BotChatState.Draft 里
// 记下的 choice——data 里那个 bot id 是服务端渲染键盘时写进去的,不是客户端传的。
// 即便如此,每个动作执行前仍重新按 owner 解析一次myBotForUser 只返回 owner 名下
// 的 bot伪造/重放的 token 顶多命中一个不在自己表里的 key被直接拒绝。
const (
mybotsCommand = "mybots"
mybotsStepMenu = "menu"
mybotsDraftGeneration = "gen"
mybotsDraftPage = "page"
mybotsDraftOptionPrefix = "opt:"
// 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:<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(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
}