- 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.
823 lines
32 KiB
Go
823 lines
32 KiB
Go
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:<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
|
||
}
|