internal/app/bots/botfather.go
Rama main · réplica de lectura del repositorio original.
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 dialog state machine: each private message a user sends to BotFather enters OnPrivateMessage via the
// app/messages responder hook; the user's message has already been stored first, so here we only generate and
// write BotFather's reply (the full SendPrivateText chain: both boxes + events + outbox push).
const (
botFatherCmdNewBot = "newbot"
botFatherCmdToken = "token"
botFatherCmdRevoke = "revoke"
botFatherCmdSetName = "setname"
botFatherCmdSetDescription = "setdescription"
botFatherCmdSetAbout = "setabouttext"
botFatherCmdSetCommands = "setcommands"
botFatherCmdSetInline = "setinline"
botFatherCmdSetInlineGeo = "setinlinegeo"
botFatherCmdSetInlineFB = "setinlinefeedback"
botFatherCmdSetJoinGroups = "setjoingroups"
botFatherCmdSetPrivacy = "setprivacy"
botFatherCmdSetUserPic = "setuserpic"
botFatherCmdDeleteBot = "deletebot"
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
botFatherCmdDone = "done"
botFatherStepName = "name"
botFatherStepUsername = "username"
botFatherStepChoose = "choose"
botFatherStepValue = "value"
botFatherStepConfirm = "confirm"
botFatherDraftBotID = "bot_id"
botFatherDraftBotUsername = "bot_username"
maxTelegramLoginCommandsPerMessage = 32
)
// botFatherHelpText lists commands grouped by function like the official BotFather, rather than one flat long
// table: the readability of /help in the client depends directly on this layout. Commands the official BotFather
// has but this server does not (/setuserpic) are explicitly marked unsupported -- the help text does not promise
// what cannot be done; the extra Telegram Login commands this server adds are listed in their own section, so
// they do not mix with the official entries.
func botFatherHelpText() string {
return `I can help you create and manage ` + branding.ProductName() + ` bots.
You can control me by sending these commands:
/newbot - create a new bot
/mybots - edit your bots
Edit Bots
/setname - change a bot's name
/setdescription - change bot description
/setabouttext - change bot about info
/setuserpic - change bot profile photo (not available on this server yet)
/setcommands - change the list of commands
/deletebot - delete a bot
Bot Settings
/token - get authorization token
/revoke - revoke bot access token
/setinline - toggle inline mode
/setinlinegeo - toggle inline location requests
/setinlinefeedback - change inline feedback settings
/setjoingroups - can your bot be added to groups?
/setprivacy - toggle privacy mode in groups
Telegram Login
/setlogin - configure Telegram Login for a bot
/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 is one reply from a built-in service bot. ReplyMarkup is an optional inline keyboard snapshot (used
// by @verifybot's button dialog); it is validated via domain.ValidateReplyMarkup before being stored.
type botReply struct {
Text string
Entities []domain.MessageEntity
ReplyMarkup *domain.MessageReplyMarkup
Media *domain.MessageMedia
}
// HandlesBot reports whether the recipient is a built-in responder bot (messages.BotResponder implementation).
func (s *Service) HandlesBot(botUserID int64) bool {
if s == nil {
return false
}
if s.premium != nil && botUserID == s.premium.BotUserID() {
return true
}
if botUserID == gramsrvOperatorBotUserID && len(s.gramsrvOperators) > 0 && s.gramsrvFreezes != nil {
return true
}
switch botUserID {
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
domain.VerifyBotUserID, domain.VerifierBotUserID:
return true
default:
return false
}
}
// OnPrivateMessage handles private messages delivered to a built-in bot (messages.BotResponder implementation).
// msg is the recipient box row from the bot's perspective. Replies are generated asynchronously (not occupying
// the user's sendMessage RPC goroutine -- official bot replies arrive asynchronously anyway); a failure only logs
// and never affects the user's message itself.
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message, session domain.ClientSessionMetadata) {
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.Body)
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)
case gramsrvOperatorBotUserID:
go s.respondAsGramsrvOperator(userID, msg.Body)
default:
if s.premium != nil && botUserID == s.premium.BotUserID() {
go s.respondAsPremium(botUserID, userID, msg, session)
}
}
}
// respondAsBotFather generates and writes the BotFather reply (called by OnPrivateMessage inside a goroutine).
// Serialized per user by stripe lock: the state machine's Get->modify->Upsert/Delete RMW is therefore atomic and
// replies stay ordered, while different users run concurrently unaffected. ctx uses Background (detached from the
// already-returned user RPC) with a long timeout.
func (s *Service) respondAsBotFather(userID int64, body string) {
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, body)
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 == "" && reply.Media.IsZero()) {
return domain.SendPrivateTextResult{}, false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
// Keyboard validation must precede persistence (I9): a structurally invalid markup is never written to the
// store, but the body is still sent -- the user at least receives the prompt text and is not completely cut
// off by one bad button.
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),
Media: reply.Media,
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
}
// botReplyRandomID constructs a non-zero idempotency key for a server reply (the (sender, random_id) unique index).
// All service-bot replies are unique per their own sender namespace -- using crypto/rand for a 64-bit random number
// (collision probability negligible), falling back to nanoseconds + a monotonic sequence when the entropy source fails.
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 are global commands handled as commands in any state. Other text starting with "/" (such
// as /empty, or the pasted first line of a "/start - Begin" command list) must be passed through to the state
// machine as raw content during a value step; otherwise /setcommands' /empty would be unreachable, and a
// slash-prefixed command list's first line would be truncated into the command name "start", silently destroying
// the whole flow.
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,
botFatherCmdSetUserPic: true, botFatherCmdDeleteBot: true,
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
text = strings.TrimSpace(text)
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()
}
// Command interception: dispatch as a command only when not in a value step, or when the text is a known
// global command. Non-global "/..." text during a value step (/empty, a command list's first line) must be
// passed through to the state machine as a raw value.
if cmd, ok := parseBotCommand(text); ok {
inValueStep := found && state.Step == botFatherStepValue
if !inValueStep || botFatherGlobalCommands[cmd] {
return s.handleBotFatherCommand(ctx, userID, cmd)
}
}
if text == "" {
// Blank text / sticker / captionless media: when there is an active state, return the current step prompt;
// when there is no state, stay silent (to avoid spamming on arbitrary non-text messages).
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 == botFatherStepConfirm:
return s.handleBotConfirm(ctx, state, text)
case state.Step == botFatherStepValue:
return s.handleSetValue(ctx, state, text)
default:
// Unreachable dirty state: clear it and start over, so the user is not stuck.
_ = 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 is the set of "choose a bot first" commands (after the choose step, dispatch by command).
var pickerPrompts = map[string]string{
botFatherCmdToken: "Choose a bot to get Bot API access token.",
botFatherCmdRevoke: "Choose a bot to revoke the token of. Send the bot's username:",
botFatherCmdSetName: "Choose a bot to change name.",
botFatherCmdSetDescription: "Choose a bot to change description.",
botFatherCmdSetAbout: "Choose a bot to change the about section.",
botFatherCmdSetCommands: "Choose a bot to change the list of commands.",
botFatherCmdSetInline: "Choose a bot to change inline queries status.",
botFatherCmdSetInlineGeo: "Choose a bot to change inline location request settings.",
botFatherCmdSetJoinGroups: "Choose a bot to change group membership settings.",
botFatherCmdSetPrivacy: "Choose a bot to change group messages settings.",
botFatherCmdDeleteBot: "Choose a bot to delete. 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 lists the owner's bots and enters the choose step (shared by all commands that first require
// choosing a bot). Each bot is rendered as an @username text button in a reply keyboard (below the input box, not
// a message attachment); tapping it sends the client the username, which goes through exactly the same
// manual-entry path as handleChooseBot -- so no callback query is needed, and there is no forgeable/expirable
// button data. The keyboard is single_use: selecting one collapses it automatically.
func (s *Service) startBotPicker(ctx context.Context, userID int64, cmd string) 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 {
// Do not clear state here: with no bots at all, the only thing that could be pending is a /newbot draft, so
// leave it for the user to continue creating (bot-level steps will fail when they re-check owner themselves
// and clean up on their own).
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
}
// /setinlinegeo only makes sense for bots with inline mode enabled, so the keyboard also lists only those bots.
if cmd == botFatherCmdSetInlineGeo {
owned = botFatherInlineBots(owned)
if len(owned) == 0 {
// If the command cannot start, the pending step must be cleared: otherwise, after the user sees "no
// inline bot", the next text they casually send would be executed as that prior command's step input --
// for example /setname's value collection would write that text as the bot name, or /deletebot's confirm
// would still be awaiting confirmation.
s.clearState(ctx, userID)
return botReply{Text: "You don't have any bots with inline mode enabled. Use /setinline to enable it first."}
}
}
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()
}
reply := botReply{Text: pickerPrompts[cmd]}
var buttons []domain.MarkupButton
for _, b := range owned {
if b.user.Username == "" {
continue
}
buttons = append(buttons, domain.MarkupButton{
Type: domain.MarkupButtonText,
Text: "@" + b.user.Username,
})
}
// Two per row: an owner usually has more than one bot, and two columns is half as tall as one.
var rows [][]domain.MarkupButton
for len(buttons) > 0 {
n := min(2, len(buttons))
rows = append(rows, buttons[:n])
buttons = buttons[n:]
}
if len(rows) > 0 {
reply.ReplyMarkup = &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: rows,
Resize: true,
SingleUse: true,
}
}
return reply
}
// botFatherInlineBots keeps only bots with inline mode enabled (a non-empty placeholder means enabled).
func botFatherInlineBots(owned []ownedBot) []ownedBot {
out := make([]ownedBot, 0, len(owned))
for _, b := range owned {
if b.profile.InlinePlaceholder != "" {
out = append(out, b)
}
}
return out
}
// botFatherToggleKeyboard is a two-button Enable/Disable reply keyboard; tapping sends the corresponding text,
// going through exactly the manual-entry path already supported by applyToggle/applySetInlineGeo.
func botFatherToggleKeyboard() *domain.MessageReplyMarkup {
return &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{
{
{Type: domain.MarkupButtonText, Text: "Enable"},
{Type: domain.MarkupButtonText, Text: "Disable"},
},
},
Resize: true,
SingleUse: true,
}
}
// stepPrompt returns the guidance text for the current dialog step (used as the fallback for empty input).
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: "Tap one of the buttons above, or send the username of one of your bots, or /cancel."}
case state.Step == botFatherStepConfirm:
return botReply{Text: botDeleteConfirmPrompt(state.Draft[botFatherDraftBotUsername])}
case state.Step == botFatherStepValue:
return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])}
default:
return botReply{Text: "Send /help for a list of commands."}
}
}
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":
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."}
}
return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")}
case botFatherCmdToken, botFatherCmdRevoke, botFatherCmdDeleteBot,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
return s.startBotPicker(ctx, userID, cmd)
case "confirm":
return s.handleBotFatherConfirmCommand(ctx, userID)
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."}
case botFatherCmdSetUserPic:
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: "Sorry, changing a bot's profile photo is not supported on this server yet. Send /help for a list of commands."}
default:
return botReply{Text: "Unrecognized command. Say what? Send /help for a list of commands."}
}
}
// valuePrompts is the value-collection prompt for the value step after a bot is selected (by command).
func valuePrompt(cmd, username string) string {
switch cmd {
case botFatherCmdSetName:
return "OK. Send me the new name for your bot."
case botFatherCmdSetDescription:
return "OK. Send me the new description for the bot. People will see this description when they open a chat with your bot, in a block titled 'What can this bot do?'."
case botFatherCmdSetAbout:
return "OK. Send me the new 'About' text. 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."
case botFatherCmdSetCommands:
return "OK. Send me a list of commands for your bot. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to keep the list empty."
case botFatherCmdSetInline:
return fmt.Sprintf("This will enable inline queries for your bot. https://core.telegram.org/bots/inline. Use /empty to disable.\n\nTo enable inline mode, please send me the placeholder message for queries to your bot. People will see this placeholder whenever they type @%s in the message field – in any chat. Tell them what they can get from your bot (e.g., Search GIFs...).", username)
case botFatherCmdSetInlineGeo:
return fmt.Sprintf("If enabled, @%s will ask for permission to get the user's location each time they send a request.\n\nSend 'enable' or 'disable'.", username)
case botFatherCmdSetJoinGroups:
return "'Enable' - bot can be added to groups.\n'Disable' - block group invitations, the bot can't be added to groups.\n\nSend 'enable' or 'disable'."
case botFatherCmdSetPrivacy:
return "'Enable' - your bot will only receive messages that either start with the '/' symbol or mention the bot by username.\n'Disable' - your bot will receive all messages that people send to groups.\n\nSend 'enable' or 'disable'."
case botFatherCmdSetLogin:
return telegramLoginConfigurationPrompt(username)
default:
return "Send the new value, or /cancel."
}
}
func inlineGeoPrompt(username string, enabled bool) string {
status := "DISABLED"
if enabled {
status = "ENABLED"
}
return fmt.Sprintf("If enabled, @%s will ask for permission to get the user's location each time they send a request.\nCurrent value is: %s.", username, status)
}
func joinGroupsPrompt(allowed bool) string {
status := "DISABLED"
if allowed {
status = "ENABLED"
}
return fmt.Sprintf("'Enable' - bot can be added to groups.\n'Disable' - block group invitations, the bot can't be added to groups.\nCurrent status is: %s", status)
}
func privacyPrompt(enabled bool) string {
status := "DISABLED"
if enabled {
status = "ENABLED"
}
return fmt.Sprintf("'Enable' - your bot will only receive messages that either start with the '/' symbol or mention the bot by username.\n'Disable' - your bot will receive all messages that people send to groups.\nCurrent status is: %s", status)
}
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):
// The name step already validated; this can only be dirty state; re-run the name step.
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, s.botAPIHint()+"\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
}
// botAPIHint names this server's Bot API base URL, when one is configured.
func (s *Service) botAPIHint() string {
if s.botAPIURL == "" {
return ""
}
return "\n\nBot API on this server: " + s.botAPIURL + "/bot<token>/<method>"
}
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."}
}
return s.applyBotChoice(ctx, state, *chosen)
}
// applyBotChoice is the single entry point that executes the command for the choose step after the bot is
// confirmed to belong to that user -- both direct username entry and tapping the @username button go through here.
func (s *Service) applyBotChoice(ctx context.Context, state domain.BotChatState, chosen domain.User) botReply {
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 := "You can use this token to access HTTP API:\n"
return tokenReply(head, domain.FormatBotToken(chosen.ID, profile.TokenSecret), s.botAPIHint()+"\n\nFor a description of the Bot API, see this page: https://core.telegram.org/bots/api")
case botFatherCmdRevoke:
defer s.clearState(ctx, state.UserID)
token, err := s.RevokeBotToken(ctx, state.UserID, chosen.ID)
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
// The token has been changed (the old token can no longer log in), but established sessions could not
// be terminated -- honestly tell the user to retry, rather than falsely claiming the bleeding has stopped.
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:
// After selecting a bot, enter the value step and stash the target bot in 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()
}
switch state.Command {
case botFatherCmdSetInlineGeo, botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
profile, found, err := s.bots.GetBot(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: get bot settings", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
if !found {
s.clearState(ctx, state.UserID)
return botReply{Text: "That bot is no longer available."}
}
reply := botReply{ReplyMarkup: botFatherToggleKeyboard()}
switch state.Command {
case botFatherCmdSetInlineGeo:
reply.Text = inlineGeoPrompt(chosen.Username, profile.InlineGeo)
case botFatherCmdSetJoinGroups:
reply.Text = joinGroupsPrompt(!profile.Nochats)
default:
// When privacy mode is on, the bot only sees commands/replies, i.e. profile.ChatHistory=false.
reply.Text = privacyPrompt(!profile.ChatHistory)
}
return reply
default:
return botReply{Text: valuePrompt(state.Command, chosen.Username)}
}
case botFatherCmdDeleteBot:
// Deleting a bot is irreversible and terminates all its sessions, so stop at the confirm step first: only an
// explicit confirmation word proceeds; an accidentally triggered username/empty message returns to the same
// confirmation prompt.
state.Step = botFatherStepConfirm
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: botDeleteConfirmPrompt(chosen.Username)}
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
}
// handleBotFatherConfirmCommand handles an explicit /confirm (it does not guess when there is no active confirm step).
func (s *Service) handleBotFatherConfirmCommand(ctx context.Context, userID int64) botReply {
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 || state.Step != botFatherStepConfirm {
return botReply{Text: "There is nothing to confirm right now. Send /help for a list of commands."}
}
return s.handleBotConfirm(ctx, state, "confirm")
}
func botDeleteConfirmPrompt(username string) string {
return fmt.Sprintf("Are you sure you want to delete @%s? This cannot be undone: the token stops working and every session of the bot is terminated.\n\nSend `confirm` to proceed, or /cancel.", username)
}
// handleBotConfirm handles the confirmation step of an irreversible operation (currently only /deletebot). Only an
// explicit confirmation word acts: any other input returns to the same confirmation prompt, so accidentally touched
// messages are not treated as confirmation.
func (s *Service) handleBotConfirm(ctx context.Context, state domain.BotChatState, text string) botReply {
if state.Command != botFatherCmdDeleteBot {
s.clearState(ctx, state.UserID)
return internalReply()
}
username := state.Draft[botFatherDraftBotUsername]
switch strings.ToLower(strings.TrimSpace(text)) {
case "confirm", "yes", "delete":
default:
return botReply{Text: botDeleteConfirmPrompt(username)}
}
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
if botID == 0 || username == "" {
s.clearState(ctx, state.UserID)
return botReply{Text: "Something went wrong, I forgot which bot we were deleting. Send /deletebot to start again."}
}
// Defensive owner re-check (the state is server-stored; normally it is already an 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."}
}
// DeleteBot first revokes sessions and then deletes the account: if revocation fails nothing was deleted, so be honest about it and keep the state for the user to retry.
if _, err := s.DeleteBot(ctx, botID); err != nil {
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
return botReply{Text: fmt.Sprintf("I couldn't delete @%s because I couldn't terminate the sessions it is already logged into. Nothing was deleted — please run /deletebot again in a moment.", username)}
case errors.Is(err, domain.ErrBotDeletionUnsupported):
s.clearState(ctx, state.UserID)
return botReply{Text: "Sorry, deleting bots is not supported on this server."}
case errors.Is(err, domain.ErrBotNotFound):
s.clearState(ctx, state.UserID)
return botReply{Text: "That bot is no longer available."}
default:
s.log.Error("botfather: delete bot", zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
}
}
s.clearState(ctx, state.UserID)
return botReply{Text: fmt.Sprintf("Success! @%s has been deleted. Its token no longer works and all of its sessions have been terminated.", username)}
}
// handleSetValue handles the value-collection step after a bot is selected (/setname etc.).
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply {
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."}
}
// Defensive owner re-check (the state is server-stored; normally it is already an 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, "Success! Name updated. /help", "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. You will be able to see the changes within a few minutes. /help", "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. /help", "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 botFatherCmdSetLogin:
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
}
if err != nil {
// Validation errors have already been turned into prompt text; non-validation errors are logged internally. Keep the state so the user can retry.
if reply.Text == "" {
return internalReply()
}
return reply
}
s.clearState(ctx, state.UserID)
return reply
}
// botFatherCommandListInvalidText is /setcommands' unified rejection text: format errors and invalid content
// (non-compliant command names/descriptions) are the same thing to the user -- resend a compliant list or /empty.
func botFatherCommandListInvalidText() string {
return `Sorry, the list of commands is invalid. Please use this format:
command1 - Description
command2 - Another description
You can also send /empty to clear the list or keep it empty.`
}
// applySetCommands parses multi-line "command - Description" (/empty clears) and writes it.
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: botFatherCommandListInvalidText()}, 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: botFatherCommandListInvalidText()}, err
}
return botReply{Text: "Success! Command list updated. /help"}, nil
}
// applySetInline writes the inline placeholder; /empty clears it and disables 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 request settings updated. New status is %s /help", 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
}
// applyToggle parses enable/disable and sets joingroups (join=true) or privacy (join=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
}
state := "disabled"
if on {
state = "enabled"
}
return botReply{Text: fmt.Sprintf("Success! The new status is: %s. /help", strings.ToUpper(state))}, nil
}
// okReply turns a service call result into a prompt: err==nil returns success, otherwise returns the validation-failure prompt.
func okReply(err error, ok, fail string) botReply {
if err != nil {
return botReply{Text: fail}
}
return botReply{Text: ok}
}
// clearState deletes the BotFather dialog state (errors ignored, only logged).
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 assembles a token message with a code entity (all-ASCII text, so the offset equals both the byte
// and UTF-16 code unit offset).
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 parses a leading "/cmd" (tolerating "/cmd@BotFather" and trailing arguments) and returns the lowercased command name.
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
}