owpengram-server/internal/app/bots/botfather.go
Astra 6649b70d5e branding: make product identity runtime-configurable
Adopt upstream owpengram/owpengram-server's branding.Config/Configure/
Current in place of the old package-level constants. This is the piece the
earlier merge attempt was blocked on (internal/branding failed to import
during that merge). The default identity is unchanged -- every existing
ProductName/ProductUsername/... default still reads "OwpenGram" -- so this
is a pure capability add: nothing currently calls Configure, and every call
site now reads the current snapshot via a function instead of a compile-time
constant.

Five string templates that concatenated branding.ProductName into a `const`
had to become `var`, since a func call is no longer a valid const operand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:37:36 +01:00

1214 lines
52 KiB
Go
Raw 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"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
// BotFather 对话状态机:用户发给 BotFather 的每条私聊消息经 app/messages 的
// responder hook 进入 OnPrivateMessage用户消息已先行入库这里只负责生成并
// 写入 BotFather 的回复(完整 SendPrivateText 链路:双盒+事件+outbox 推送)。
const (
botFatherCmdNewBot = "newbot"
botFatherCmdToken = "token"
botFatherCmdRevoke = "revoke"
botFatherCmdSetName = "setname"
botFatherCmdSetBotpic = "setbotpic"
botFatherCmdSetDescription = "setdescription"
botFatherCmdSetAbout = "setabouttext"
botFatherCmdSetCommands = "setcommands"
botFatherCmdSetInline = "setinline"
botFatherCmdSetInlineGeo = "setinlinegeo"
botFatherCmdSetInlineFB = "setinlinefeedback"
botFatherCmdSetJoinGroups = "setjoingroups"
botFatherCmdSetPrivacy = "setprivacy"
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
botFatherCmdDone = "done"
botFatherStepName = "name"
botFatherStepUsername = "username"
botFatherStepChoose = "choose"
botFatherStepValue = "value"
botFatherDraftBotID = "bot_id"
botFatherDraftBotUsername = "bot_username"
maxTelegramLoginCommandsPerMessage = 32
)
var botFatherHelpText = `I can help you create and manage ` + branding.ProductName() + ` bots.
You can control me by sending these commands:
/newbot - create a new bot
/mybots - list your bots
/token - show a bot's token
/revoke - revoke a bot's token
/setname - change a bot's name
/setdescription - change a bot's description
/setabouttext - change a bot's about info
/setcommands - change a bot's command list
/setinline - toggle inline mode
/setinlinegeo - toggle inline location requests
/setinlinefeedback - change inline feedback settings
/setjoingroups - toggle whether a bot can join groups
/setprivacy - toggle a bot's group privacy mode
/setlogin - configure Telegram Login allowed URLs and signing
/logininfo - show a bot's Telegram Login configuration
/resetloginsecret - rotate a bot's OIDC Client Secret
/done - finish the active Telegram Login configuration
/cancel - cancel the current operation
/help - show this message`
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
type botReply struct {
Text string
Entities []domain.MessageEntity
ReplyMarkup *domain.MessageReplyMarkup
}
// HandlesBot 报告该收件人是否为内置应答 botmessages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
if s == nil {
return false
}
switch botUserID {
case domain.VerifierBotUserID:
// @marksbot fronts THIRD-PARTY verification, which is hidden by default
// (config.HideThirdPartyVerification) because the feature is not fully
// finished -- while hidden, the bot doesn't exist as far as the message
// pipeline is concerned.
return !s.hideThirdPartyVerification
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
domain.VerifyBotUserID:
return true
default:
return false
}
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息messages.BotResponder 实现)。
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
if s == nil || s.messages == nil || !s.HandlesBot(botUserID) {
return
}
userID := msg.From.ID
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
return
}
switch botUserID {
case domain.BotFatherUserID:
go s.respondAsBotFather(userID, msg)
case domain.StickersBotUserID:
go s.respondAsStickers(userID, msg)
case domain.ChatBotUserID:
go s.respondAsChatBot(userID, msg)
case domain.VerifyBotUserID:
go s.respondAsVerify(userID, msg)
case domain.VerifierBotUserID:
go s.respondAsVerifier(userID, msg)
}
}
// respondAsBotFather 生成并写入 BotFather 回复OnPrivateMessage 在 goroutine 内调用)。
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
// 不同用户并发不受影响。ctx 用 Background脱离已返回的用户 RPC限较长超时。
func (s *Service) respondAsBotFather(userID int64, msg domain.Message) {
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
reply := s.handleBotFather(ctx, userID, msg)
s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply)
}
func (s *Service) serviceBotReplyLock(botUserID, userID int64) *sync.Mutex {
key := uint64(userID) ^ (uint64(botUserID) * 11400714819323198485)
return &s.replyLocks[key%replyLockStripes]
}
func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int64, reply botReply) {
_, _ = s.sendServiceBotReplyResult(ctx, botUserID, userID, reply)
}
func (s *Service) serviceBotRecipientBlocked(ctx context.Context, botUserID, userID int64) bool {
if s == nil || s.blocker == nil {
return false
}
blocked, err := s.blocker.IsBlocked(ctx, userID, botUserID)
if err != nil {
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return false
}
return blocked
}
func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, userID int64, reply botReply) (domain.SendPrivateTextResult, bool) {
if s == nil || s.messages == nil || reply.Text == "" {
return domain.SendPrivateTextResult{}, false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
// 键盘校验必须先于落库I9结构非法的 markup 绝不写库,但正文仍然发出
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
s.log.Error("service bot: invalid reply markup",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: botUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
ReplyMarkup: markup,
Date: int(s.now().Unix()),
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
})
if err != nil {
s.log.Error("service bot: send reply", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return domain.SendPrivateTextResult{}, false
}
return res, true
}
// editServiceBotMessage 就地改写一条内置 bot 自己发出的消息(正文 + inline keyboard
// 用于按钮式菜单(@BotFather /mybots在点击后原地翻页/下钻,而不是刷屏新消息。
// OwnerUserID 取 bot 自身store 的 authorEdit 判定要求 sender==from==ownerbot 发出的
// 那一份 outbox 拷贝正好满足。ErrMessageNotModified 视为成功(点了同一个按钮两次)。
func (s *Service) editServiceBotMessage(ctx context.Context, botUserID, userID int64, messageID int, reply botReply) bool {
if s == nil || s.messages == nil || messageID <= 0 || reply.Text == "" {
return false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
s.log.Error("service bot: invalid reply markup for edit",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
_, err := s.messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: botUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
ID: messageID,
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
EditDate: int(s.now().Unix()),
HideEdited: true,
SetReplyMarkup: true,
ReplyMarkup: markup,
})
if errors.Is(err, domain.ErrMessageNotModified) {
return true
}
if err != nil {
s.log.Error("service bot: edit reply",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return false
}
return true
}
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
func (s *Service) botReplyRandomID() int64 {
if v, err := randomInt64(); err == nil && v != 0 {
return v
}
v := s.now().UnixNano() + s.replySeq.Add(1)
if v == 0 {
v = 1
}
return v
}
// botFatherGlobalCommands 是任何状态下都优先按命令处理的全局命令。其余以 "/"
// 开头的文本(如 /empty、或粘贴的 "/start - Begin" 命令列表首行)在收值步骤里
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
var botFatherGlobalCommands = map[string]bool{
"start": true, "help": true, "cancel": true, botFatherCmdDone: true,
botFatherCmdNewBot: true, "mybots": true,
botFatherCmdToken: true, botFatherCmdRevoke: true,
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, msg domain.Message) botReply {
text := strings.TrimSpace(msg.Body)
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
// 命令拦截:仅当不在收值步骤、或文本是已知全局命令时,才走命令分发。收值步骤
// 下的非全局 "/..." 文本(/empty、命令列表首行必须当原始值透传给状态机。
if cmd, ok := parseBotCommand(text); ok {
inValueStep := found && state.Step == botFatherStepValue
if !inValueStep || botFatherGlobalCommands[cmd] {
// "/start <bot>" (the "Manage Bot" deep link) jumps straight to that
// bot's menu, like /mybots then tapping the bot.
if cmd == "start" {
if arg := botCommandArg(text); arg != "" {
return s.handleBotFatherStart(ctx, userID, arg)
}
}
return s.handleBotFatherCommand(ctx, userID, cmd)
}
}
if text == "" {
// 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示,
// 无状态保持沉默(避免对任意非文本消息刷屏)。
if found && state.Step == botFatherStepValue && msg.Media != nil {
// A photo for /setbotpic arrives with no caption: hand the message to
// the value step instead of replaying the prompt.
return s.handleSetValue(ctx, state, msg)
}
if !found {
return botReply{}
}
return s.stepPrompt(state)
}
if !found {
return botReply{Text: "I can only help you create and manage bots. Send /help for a list of commands."}
}
switch {
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
return s.handleNewBotName(ctx, state, text)
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
return s.handleNewBotUsername(ctx, state, text)
case state.Step == botFatherStepChoose:
return s.handleChooseBot(ctx, state, text)
case state.Step == botFatherStepValue:
return s.handleSetValue(ctx, state, msg)
case state.Command == mybotsCommand:
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
default:
// 不可达的脏状态:清掉重来,避免用户被卡死。
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: "Something went wrong, I forgot what we were doing. Send /help for a list of commands."}
}
}
// pickerCommands 是「先选 bot」的命令集choose step 后按命令分流)。
var pickerPrompts = map[string]string{
botFatherCmdToken: "Choose a bot to generate a token for. Send the bot's username:",
botFatherCmdRevoke: "Choose a bot to revoke the token of. Send the bot's username:",
botFatherCmdSetName: "Choose a bot to change the name of. Send the bot's username:",
botFatherCmdSetDescription: "Choose a bot to change the description of. Send the bot's username:",
botFatherCmdSetAbout: "Choose a bot to change the about info of. Send the bot's username:",
botFatherCmdSetCommands: "Choose a bot to change the command list of. Send the bot's username:",
botFatherCmdSetInline: "Choose a bot to change inline mode for. Send the bot's username:",
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
}
// startBotPicker 列出 owner 的 bot 并进入 choose step所有需先选 bot 的命令共用)。
func (s *Service) startBotPicker(ctx context.Context, userID int64, cmd string) botReply {
usernames, err := s.ownedBotUsernames(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if len(usernames) == 0 {
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
}
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: cmd,
Step: botFatherStepChoose,
}); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: pickerPrompts[cmd] + "\n\n@" + strings.Join(usernames, "\n@")}
}
// stepPrompt 返回当前对话步骤的引导文案(空输入兜底用)。
func (s *Service) stepPrompt(state domain.BotChatState) botReply {
switch {
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName:
return botReply{Text: "Please choose a name for your bot, or /cancel."}
case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername:
return botReply{Text: "Please send a username for your bot. It must end in `bot`. Or /cancel."}
case state.Step == botFatherStepChoose:
return botReply{Text: "Please send the username of one of your bots, or /cancel."}
case state.Step == botFatherStepValue:
return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])}
case state.Command == mybotsCommand:
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
default:
return botReply{Text: "Send /help for a list of commands."}
}
}
// handleBotFatherStart answers "/start <arg>". When <arg> names one of the
// user's own bots (by username or numeric id) it opens that bot's menu - the
// same "What do you want to do?" screen as /mybots then tapping the bot, which
// is what the "Manage Bot" button on a bot's profile links to. An empty or
// unknown arg falls back to the plain greeting.
func (s *Service) handleBotFatherStart(ctx context.Context, userID int64, arg string) botReply {
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
want := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(arg), "@"))
if want == "" {
return botReply{Text: botFatherHelpText}
}
owned, err := s.ownedBots(ctx, userID)
if err != nil {
s.log.Error("botfather: list bots for start payload", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
for _, b := range owned {
if strings.EqualFold(b.user.Username, want) || strconv.FormatInt(b.user.ID, 10) == want {
state := domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: mybotsCommand,
Step: mybotsStepMenu,
Draft: map[string]string{},
}
reply := s.myBotsBotMenu(&state, b)
if !s.saveMyBotsState(ctx, state) {
return internalReply()
}
return reply
}
}
return botReply{Text: botFatherHelpText}
}
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
switch cmd {
case "start", "help":
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: botFatherHelpText}
case "cancel":
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if !found {
return botReply{Text: "No active command to cancel. I wasn't doing anything anyway."}
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
}
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
case botFatherCmdDone:
return s.finishTelegramLoginConfiguration(ctx, userID)
case botFatherCmdNewBot:
count, err := s.bots.CountBotsByOwner(ctx, userID)
if err != nil {
s.log.Error("botfather: count bots", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if count >= domain.MaxBotsPerOwner {
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
}
if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: botFatherCmdNewBot,
Step: botFatherStepName,
}); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."}
case "mybots":
return s.startMyBots(ctx, userID)
case botFatherCmdToken, botFatherCmdRevoke,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
return s.startBotPicker(ctx, userID, cmd)
case botFatherCmdSetInlineFB:
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: "Inline feedback settings are not supported yet. Use /setinline to enable or disable inline mode."}
default:
return botReply{Text: "Unrecognized command. Say what? Send /help for a list of commands."}
}
}
// valuePrompts 是选中 bot 后、value step 的收值提示(按命令)。
func valuePrompt(cmd, username string) string {
switch cmd {
case botFatherCmdSetName:
return fmt.Sprintf("OK. Send me the new name for @%s.", username)
case botFatherCmdSetBotpic:
return fmt.Sprintf("OK. Send me the new profile picture for @%s. Send it as a photo.", username)
case botFatherCmdSetDescription:
return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username)
case botFatherCmdSetAbout:
return fmt.Sprintf("OK. Send me the new about text for @%s. People will see this text on the bot's profile page and it will be sent together with a link to your bot when they share it with someone.", username)
case botFatherCmdSetCommands:
return fmt.Sprintf("OK. Send me a list of commands for @%s. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to clear the list.", username)
case botFatherCmdSetInline:
return fmt.Sprintf("This will enable inline queries for @%s. Send me the placeholder text people will see after typing the bot username, or send /empty to disable inline mode.", username)
case botFatherCmdSetInlineGeo:
return fmt.Sprintf("Send 'enable' to allow @%s to receive location in inline queries, or 'disable' to turn it off.", username)
case botFatherCmdSetJoinGroups:
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
case botFatherCmdSetPrivacy:
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
case botFatherCmdSetLogin:
return telegramLoginConfigurationPrompt(username)
default:
return "Send the new value, or /cancel."
}
}
func (s *Service) handleNewBotName(ctx context.Context, state domain.BotChatState, name string) botReply {
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
return botReply{Text: fmt.Sprintf("Sorry, the bot name must be 1-%d characters long. Please choose a different name.", domain.MaxBotNameLength)}
}
state.Step = botFatherStepUsername
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft["name"] = name
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot."}
}
func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChatState, username string) botReply {
u, token, err := s.CreateBot(ctx, state.UserID, state.Draft["name"], username)
switch {
case errors.Is(err, domain.ErrBotUsernameInvalid):
return botReply{Text: "Sorry, this username is invalid. A bot username must be 5-32 characters long, start with a letter, contain only Latin letters, digits and underscores, and end in 'bot' (e.g. tetris_bot)."}
case errors.Is(err, domain.ErrUsernameOccupied):
return botReply{Text: "Sorry, this username is already taken. Please try something different."}
case errors.Is(err, domain.ErrBotsTooMany):
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID)
return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)}
case errors.Is(err, domain.ErrBotNameInvalid):
// name 步已校验,这里只可能是脏状态;重新走 name 步。
state.Step = botFatherStepName
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Please choose a name for your bot first."}
case err != nil:
s.log.Error("botfather: create bot", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
}
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at %s.\n\nUse this token to access the HTTP API:\n", s.publicURL(u.Username))
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
}
func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState, text string) botReply {
username := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(text, "@")))
profiles, 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 internalReply()
}
var chosen *domain.User
for i := range profiles {
if strings.ToLower(profiles[i].user.Username) == username {
chosen = &profiles[i].user
break
}
}
if chosen == nil {
return botReply{Text: "I don't see that bot among yours. Send the username of one of your bots, or /cancel."}
}
switch state.Command {
case botFatherCmdToken:
defer s.clearState(ctx, state.UserID)
profile, found, err := s.bots.GetBot(ctx, chosen.ID)
if err != nil || !found || profile.TokenSecret == "" {
if err != nil {
s.log.Error("botfather: get bot", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
}
return internalReply()
}
head := fmt.Sprintf("You can use this token to access the HTTP API for @%s:\n", chosen.Username)
return tokenReply(head, domain.FormatBotToken(chosen.ID, profile.TokenSecret), "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdRevoke:
defer s.clearState(ctx, state.UserID)
token, err := s.RevokeBotToken(ctx, state.UserID, chosen.ID)
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
// token 已换(旧 token 不可再登录),但未能终止已建立的 session——
// 诚实告知用户重试,不谎称已止血。
head := fmt.Sprintf("Token for @%s has been changed, so the old token can no longer log in. But I couldn't terminate sessions that are already logged in — please run /revoke again to make sure they're cut off. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case err != nil:
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdLoginInfo:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
if !found {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
}
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
case botFatherCmdResetLogin:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
}
if err != nil {
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
case botFatherCmdSetLogin:
if s.telegramLogin == nil {
s.clearState(ctx, state.UserID)
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
state.Step = botFatherStepValue
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
state.Draft[botFatherDraftBotUsername] = chosen.Username
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
prompt := telegramLoginConfigurationPrompt(chosen.Username)
if !created {
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
}
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
// 选中 bot 后进入收值 step把目标 bot 暂存进 Draft。
state.Step = botFatherStepValue
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
state.Draft[botFatherDraftBotUsername] = chosen.Username
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: valuePrompt(state.Command, chosen.Username)}
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
}
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。除 /setbotpic 收一张
// 照片外都是纯文本;照片经 msg.Media 传入。
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
text := strings.TrimSpace(msg.Body)
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
username := state.Draft[botFatherDraftBotUsername]
if botID == 0 {
s.clearState(ctx, state.UserID)
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /help."}
}
// 防御性复核 owner状态是服务端存的正常已是 owned bot
if owns, err := s.OwnsBot(ctx, state.UserID, botID); err != nil {
s.log.Error("botfather: owns bot", zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
} else if !owns {
s.clearState(ctx, state.UserID)
return botReply{Text: "That bot is no longer available."}
}
var (
reply botReply
err error
)
switch state.Command {
case botFatherCmdSetName:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetName: true, Name: text})
reply = okReply(err, fmt.Sprintf("Success! Name updated for @%s.", username), "Sorry, that name is invalid. Please try a different one.")
case botFatherCmdSetDescription:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetDescription: true, Description: text})
reply = okReply(err, "Success! Description updated.", "Sorry, that description is too long.")
case botFatherCmdSetAbout:
_, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetAbout: true, About: text})
reply = okReply(err, "Success! About section updated.", "Sorry, that about text is too long.")
case botFatherCmdSetCommands:
reply, err = s.applySetCommands(ctx, botID, text)
case botFatherCmdSetInline:
reply, err = s.applySetInline(ctx, botID, text)
case botFatherCmdSetInlineGeo:
reply, err = s.applySetInlineGeo(ctx, botID, text)
case botFatherCmdSetJoinGroups:
reply, err = s.applyToggle(ctx, botID, text, true)
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetBotpic:
reply, err = s.applySetBotpic(ctx, botID, username, msg)
case botFatherCmdSetLogin:
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
if err != nil {
// 校验类错误已转成提示文案;非校验错误内部已记日志。保留 state 让用户重试。
if reply.Text == "" {
return internalReply()
}
return reply
}
if state.Draft[mybotsDraftReturn] == "1" {
// Opened from the /mybots menu: land back on the Edit Bot menu (fresh
// message, working buttons) rather than ending the dialog.
return s.myBotsReturnToEditMenu(ctx, state.UserID, botID, state.Draft[mybotsDraftPage], reply.Text)
}
s.clearState(ctx, state.UserID)
return reply
}
// applySetCommands 解析多行 "command - Description"/empty 清空)并写入。
func (s *Service) applySetCommands(ctx context.Context, botID int64, text string) (botReply, error) {
var commands []domain.BotCommand
if strings.TrimSpace(text) != "/empty" {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cmd, desc, ok := strings.Cut(line, "-")
if !ok {
return botReply{Text: "Invalid format. Each line must be: command - Description. Try again or /cancel."}, domain.ErrBotCommandInvalid
}
commands = append(commands, domain.BotCommand{
Command: strings.TrimSpace(cmd),
Description: strings.TrimSpace(desc),
})
}
}
if _, err := s.SetBotCommands(ctx, botID, commands); err != nil {
return botReply{Text: "Invalid command list. Each command must be 1-32 chars (letters, digits, underscores) with a non-empty description. Try again or /cancel."}, err
}
return botReply{Text: "Success! Command list updated. /help"}, nil
}
// applySetInline 写入 inline placeholder/empty 清空并关闭 inline mode。
func (s *Service) applySetInline(ctx context.Context, botID int64, text string) (botReply, error) {
placeholder := strings.TrimSpace(text)
if placeholder == "/empty" {
placeholder = ""
}
if _, err := s.SetInlinePlaceholder(ctx, botID, placeholder); err != nil {
return botReply{Text: fmt.Sprintf("Sorry, inline placeholder must be at most %d characters. Try again or /cancel.", domain.MaxBotInlinePlaceholderLen)}, err
}
if placeholder == "" {
return botReply{Text: "Success! Inline mode disabled. /help"}, nil
}
return botReply{Text: "Success! Inline settings updated. /help"}, nil
}
func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text string) (botReply, error) {
var enabled bool
switch strings.ToLower(strings.TrimSpace(text)) {
case "enable", "on", "yes":
enabled = true
case "disable", "off", "no":
enabled = false
default:
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
}
if _, err := s.SetInlineGeo(ctx, botID, enabled); err != nil {
s.log.Error("botfather: set inline geo", zap.Int64("bot_user_id", botID), zap.Error(err))
return botReply{}, err
}
state := "disabled"
if enabled {
state = "enabled"
}
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
}
func telegramLoginConfigurationPrompt(username string) string {
return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
add origin https://example.com
add redirect https://example.com/auth/callback
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
remove origin https://example.com
remove redirect https://example.com/auth/callback
remove app 12
algorithm RS256|ES256|EdDSA|ES256K
enable
disable
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
}
func telegramLoginConfigurationContinuePrompt(username string) string {
return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
}
func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
}
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
username := state.Draft[botFatherDraftBotUsername]
if botID == 0 || username == "" {
s.clearState(ctx, userID)
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
}
owns, err := s.OwnsBot(ctx, userID, botID)
if err != nil {
s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
}
if !owns {
s.clearState(ctx, userID)
return botReply{Text: "That bot is no longer available."}
}
if s.telegramLogin == nil {
s.clearState(ctx, userID)
return botReply{Text: "Telegram Login is not enabled on this server."}
}
configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
if err != nil {
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
}
if !configured {
s.clearState(ctx, userID)
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
}
func (s *Service) handleTelegramLoginConfigurationInput(
ctx context.Context,
state domain.BotChatState,
botID int64,
username string,
text string,
) botReply {
if strings.EqualFold(strings.TrimSpace(text), "done") {
return s.finishTelegramLoginConfiguration(ctx, state.UserID)
}
lines := make([]string, 0, 4)
for _, raw := range strings.Split(text, "\n") {
if line := strings.TrimSpace(raw); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
}
if len(lines) > maxTelegramLoginCommandsPerMessage {
return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
}
applied := make([]string, 0, len(lines))
for i, line := range lines {
reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
if err != nil {
if len(lines) == 1 {
if reply.Text == "" {
return internalReply()
}
return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
}
failure := reply.Text
if failure == "" {
failure = "Something went wrong on my side. Please try that line again later."
}
var out strings.Builder
if len(applied) > 0 {
fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
}
fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
if i+1 < len(lines) {
fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
}
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
return botReply{Text: out.String()}
}
applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
}
var out strings.Builder
if len(lines) == 1 {
out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
} else {
fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
}
out.WriteString("\n\n")
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
return botReply{Text: out.String()}
}
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
status := "disabled"
if configuration.Client.Enabled {
status = "enabled"
}
var out strings.Builder
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
if len(configuration.AllowedURLs) == 0 {
out.WriteString("\nAllowed URLs: none")
} else {
out.WriteString("\nAllowed URLs:")
for _, allowed := range configuration.AllowedURLs {
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
}
}
if len(configuration.NativeApps) > 0 {
out.WriteString("\nNative apps:")
for _, app := range configuration.NativeApps {
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
}
}
return out.String()
}
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "origin":
return domain.TelegramLoginAllowedWebOrigin, true
case "redirect":
return domain.TelegramLoginAllowedRedirectURI, true
default:
return "", false
}
}
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "RS256":
return domain.TelegramLoginSigningRS256, true
case "ES256":
return domain.TelegramLoginSigningES256, true
case "EDDSA":
return domain.TelegramLoginSigningEdDSA, true
case "ES256K":
return domain.TelegramLoginSigningES256K, true
default:
return "", false
}
}
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
}
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 1 {
switch strings.ToLower(fields[0]) {
case "enable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
case "disable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
}
}
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
if !ok {
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
}
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
}
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
}
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
kind, ok := telegramLoginAllowedURLKind(fields[1])
if !ok {
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(fields[0], "add") {
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
}
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
}
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
}
if !deleted {
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
}
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
platform := domain.TelegramLoginNativeIOS
if strings.EqualFold(fields[1], "android") {
platform = domain.TelegramLoginNativeAndroid
}
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
if err != nil {
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
}
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
}
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
appID, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil || appID <= 0 {
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
if err != nil {
return botReply{}, err
}
if !deleted {
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
}
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
}
// applySetBotpic 把用户发给 BotFather 的一张照片设为 bot 头像。只收 photo不收
// 文件/贴纸);文件层重渲染成头像尺寸集。
func (s *Service) applySetBotpic(ctx context.Context, botID int64, username string, msg domain.Message) (botReply, error) {
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPhoto || msg.Media.Photo == nil || msg.Media.Photo.ID == 0 {
return botReply{Text: "Please send a photo (as a photo, not a file), or /cancel."}, domain.ErrPhotoInvalid
}
err := s.SetBotUserpic(ctx, botID, msg.Media.Photo.ID)
switch {
case errors.Is(err, ErrBotUserpicUnsupported):
return botReply{Text: "Setting a profile picture isn't available on this server."}, err
case errors.Is(err, domain.ErrPhotoInvalid):
return botReply{Text: "Sorry, I couldn't use that image. Send a JPEG or PNG photo, or /cancel."}, err
case err != nil:
s.log.Error("botfather: set botpic", zap.Int64("bot_user_id", botID), zap.Error(err))
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Success! Profile picture updated for @%s.", username)}, nil
}
// applyToggle 解析 enable/disable 并设置 joingroupsjoin=true或 privacyjoin=false
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool
switch strings.ToLower(strings.TrimSpace(text)) {
case "enable", "on", "yes":
on = true
case "disable", "off", "no":
on = false
default:
return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid
}
var err error
if join {
_, err = s.SetJoinGroups(ctx, botID, on)
} else {
_, err = s.SetPrivacy(ctx, botID, on)
}
if err != nil {
s.log.Error("botfather: apply toggle", zap.Int64("bot_user_id", botID), zap.Bool("join", join), zap.Error(err))
return botReply{}, err
}
what := "Group privacy"
if join {
what = "Group joining"
}
state := "disabled"
if on {
state = "enabled"
}
return botReply{Text: fmt.Sprintf("Success! %s is now %s.", what, state)}, nil
}
// okReply 把 service 调用结果转成提示err==nil 回成功,否则回校验失败提示。
func okReply(err error, ok, fail string) botReply {
if err != nil {
return botReply{Text: fail}
}
return botReply{Text: ok}
}
// clearState 删除 BotFather 对话状态(忽略错误,仅记日志)。
func (s *Service) clearState(ctx context.Context, userID int64) {
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
}
}
type ownedBot struct {
profile domain.BotProfile
user domain.User
}
func (s *Service) ownedBots(ctx context.Context, ownerUserID int64) ([]ownedBot, error) {
profiles, err := s.bots.ListBotsByOwner(ctx, ownerUserID)
if err != nil {
return nil, err
}
if len(profiles) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.BotUserID)
}
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return nil, err
}
byID := make(map[int64]domain.User, len(users))
for _, u := range users {
byID[u.ID] = u
}
out := make([]ownedBot, 0, len(profiles))
for _, p := range profiles {
u, ok := byID[p.BotUserID]
if !ok {
continue
}
out = append(out, ownedBot{profile: p, user: u})
}
sort.Slice(out, func(i, j int) bool { return out[i].user.ID < out[j].user.ID })
return out, nil
}
func (s *Service) ownedBotUsernames(ctx context.Context, ownerUserID int64) ([]string, error) {
owned, err := s.ownedBots(ctx, ownerUserID)
if err != nil {
return nil, err
}
out := make([]string, 0, len(owned))
for _, b := range owned {
if b.user.Username != "" {
out = append(out, b.user.Username)
}
}
return out, nil
}
// tokenReply 拼装带 code entity 的 token 消息(全 ASCII 文本offset 按字节即按
// UTF-16 code unit 成立)。
func tokenReply(head, token, tail string) botReply {
return botReply{
Text: head + token + tail,
Entities: []domain.MessageEntity{
{Type: domain.MessageEntityCode, Offset: len(head), Length: len(token)},
},
}
}
func internalReply() botReply {
return botReply{Text: "Something went wrong on my side. Please try again later."}
}
// parseBotCommand 解析行首 "/cmd"(容忍 "/cmd@BotFather" 与尾随参数),返回小写命令名。
func parseBotCommand(text string) (string, bool) {
if !strings.HasPrefix(text, "/") {
return "", false
}
cmd := text[1:]
if i := strings.IndexAny(cmd, " \t\n"); i >= 0 {
cmd = cmd[:i]
}
if i := strings.IndexByte(cmd, '@'); i >= 0 {
cmd = cmd[:i]
}
if cmd == "" {
return "", false
}
return strings.ToLower(cmd), true
}
// botCommandArg returns the trimmed argument after a leading "/cmd", e.g.
// "/start my_bot" -> "my_bot". Empty when there is no argument.
func botCommandArg(text string) string {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/") {
return ""
}
if i := strings.IndexAny(text, " \t\n"); i >= 0 {
return strings.TrimSpace(text[i+1:])
}
return ""
}