/mybots: Edit Bot summary screen, return-to-menu, clickable @mentions

- Edit Bot now shows the current value of every field (Name/About/
  Description/Botpic/Commands) like BotFather, with real botpic status
  via a new PeerHasAvatar port method.
- After editing a field the dialog lands back on a fresh Edit Bot menu
  (working "Back to bot" / "Bots list" buttons) instead of ending, so a
  follow-up button press no longer reports the button as expired.
- Service-bot messages now render @username as a tappable mention entity.
This commit is contained in:
Astra 2026-09-08 13:44:01 +01:00
parent f474a360d7
commit 5afafc60c4
7 changed files with 215 additions and 10 deletions

View file

@ -36,6 +36,10 @@ const (
mybotsDraftGeneration = "gen"
mybotsDraftPage = "page"
mybotsDraftOptionPrefix = "opt:"
// mybotsDraftReturn marks a botFatherStepValue state that was opened from the
// /mybots menu, so a successful field edit lands back on the Edit Bot menu
// (a fresh message with working buttons) instead of just ending the dialog.
mybotsDraftReturn = "mb_ret"
// mybotsCallbackDataPrefix tags this menu's callback data. It carries no
// information beyond "this is a @BotFather /mybots button".
@ -211,7 +215,7 @@ func (s *Service) applyMyBotsChoice(ctx context.Context, state domain.BotChatSta
switch {
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
return s.myBotsTokenScreen(ctx, &state, b,
fmt.Sprintf("Token for @%s changed, but I couldn't cut off sessions that are already logged in tap Revoke again to be sure.", b.user.Username))
fmt.Sprintf("Token for @%s changed, but I couldn't cut off sessions that are already logged in - tap Revoke again to be sure.", b.user.Username))
case err != nil:
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return internalReply()
@ -222,7 +226,7 @@ func (s *Service) applyMyBotsChoice(ctx context.Context, state domain.BotChatSta
case strings.HasPrefix(choice, mybotsChoiceEditPrefix):
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceEditPrefix, edit, func(b ownedBot) botReply {
return s.myBotsEditMenu(&state, b)
return s.myBotsEditMenu(ctx, &state, b)
})
case strings.HasPrefix(choice, mybotsChoiceCfgPrefix) && !strings.HasPrefix(choice, mybotsChoiceCfgInline) &&
@ -376,6 +380,8 @@ func (s *Service) mybotsBeginValueInput(ctx context.Context, state domain.BotCha
Draft: map[string]string{
botFatherDraftBotID: strconv.FormatInt(b.user.ID, 10),
botFatherDraftBotUsername: b.user.Username,
mybotsDraftReturn: "1",
mybotsDraftPage: state.Draft[mybotsDraftPage],
},
}
if err := s.bots.UpsertBotChatState(ctx, next); err != nil {
@ -482,7 +488,7 @@ func (s *Service) myBotsTokenScreenWithToken(state *domain.BotChatState, b owned
head += "\n\n"
}
head += fmt.Sprintf("Token for @%s:\n", b.user.Username)
reply := tokenReply(head, token, "\n\nKeep it secret anyone with this token controls the bot.")
reply := tokenReply(head, token, "\n\nKeep it secret - anyone with this token controls the bot.")
rows := [][]mybotsOption{
{{text: "Revoke current token", choice: mybotsChoiceRevokePrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
@ -502,21 +508,109 @@ func (s *Service) myBotsRevokeConfirm(state *domain.BotChatState, b ownedBot) bo
}
}
func (s *Service) myBotsEditMenu(state *domain.BotChatState, b ownedBot) botReply {
func (s *Service) myBotsEditMenu(ctx context.Context, state *domain.BotChatState, b ownedBot) botReply {
name, about, description, err := s.GetBotInfo(ctx, b.user.ID)
if err != nil {
s.log.Error("botfather: get bot info for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return internalReply()
}
commands, err := s.GetBotCommands(ctx, b.user.ID)
if err != nil {
s.log.Error("botfather: get bot commands for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
return internalReply()
}
page := mybotsDraftInt(*state, mybotsDraftPage)
rows := [][]mybotsOption{
{{text: "Edit Name", choice: mybotsChoiceSetNamePrefix + botID64(b)}},
{{text: "Edit Description", choice: mybotsChoiceSetDescPrefix + botID64(b)}},
{{text: "Edit About", choice: mybotsChoiceSetAboutPrefix + botID64(b)}},
{{text: "Edit Description", choice: mybotsChoiceSetDescPrefix + botID64(b)}},
{{text: "Edit Botpic", choice: mybotsChoiceBotpicPrefix + botID64(b)}},
{{text: "Edit Commands", choice: mybotsChoiceSetCmdsPrefix + botID64(b)}},
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
{
{text: " Back to bot", choice: mybotsChoiceBotPrefix + botID64(b)},
{text: " Bots list", choice: mybotsChoiceListPrefix + strconv.FormatInt(page, 10)},
},
}
return botReply{
Text: fmt.Sprintf("Editing @%s. Pick a field — I'll ask for the new value.", b.user.Username),
Text: myBotsEditSummary(b.user.Username, name, about, description, commands, s.botHasAvatar(ctx, b.user.ID)),
ReplyMarkup: s.mybotsKeyboard(state, rows),
}
}
// myBotsEditSummary renders the "Edit @bot info" screen: the current value of
// every editable field, mirroring what BotFather shows.
func myBotsEditSummary(username, name, about, description string, commands []domain.BotCommand, hasBotpic bool) string {
orNone := func(v string) string {
v = strings.ReplaceAll(strings.TrimSpace(v), "\n", " ")
if v == "" {
return "🚫"
}
if r := []rune(v); len(r) > 120 {
v = string(r[:117]) + "..."
}
return v
}
cmds := "no commands yet"
if n := len(commands); n == 1 {
cmds = "1 command"
} else if n > 1 {
cmds = fmt.Sprintf("%d commands", n)
}
botpic := "🚫 no botpic"
if hasBotpic {
botpic = "🖼 has a botpic"
}
return fmt.Sprintf(
"Edit @%s info.\n\nName: %s\nAbout: %s\nDescription: %s\nDescription picture: 🚫 no description picture\nBotpic: %s\nCommands: %s\nPrivacy Policy: 🚫",
username, orNone(name), orNone(about), orNone(description), botpic, cmds,
)
}
// botHasAvatar reports whether the bot currently has a profile photo. Without a
// file layer wired it answers false.
func (s *Service) botHasAvatar(ctx context.Context, botUserID int64) bool {
if s.botAvatar == nil {
return false
}
ok, err := s.botAvatar.PeerHasAvatar(ctx, domain.PeerTypeUser, botUserID)
if err != nil {
s.log.Warn("botfather: check bot avatar", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return false
}
return ok
}
// myBotsReturnToEditMenu rebuilds a fresh /mybots dialog on the Edit Bot menu
// after a field was edited through the shared value-input flow, so the follow-up
// message carries working "Back to bot" / "Bots list" buttons instead of the
// dialog just ending.
func (s *Service) myBotsReturnToEditMenu(ctx context.Context, userID, botID int64, page, lead string) botReply {
b, ok, err := s.myBotForUser(ctx, userID, botID)
if err != nil || !ok {
s.clearState(ctx, userID)
return botReply{Text: strings.TrimSpace(lead)}
}
st := domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: userID,
Command: mybotsCommand,
Step: mybotsStepMenu,
Draft: map[string]string{},
}
if p := strings.TrimSpace(page); p != "" {
st.Draft[mybotsDraftPage] = p
}
menu := s.myBotsEditMenu(ctx, &st, b)
if menu.ReplyMarkup == nil || !s.saveMyBotsState(ctx, st) {
s.clearState(ctx, userID)
return botReply{Text: strings.TrimSpace(lead)}
}
if lead = strings.TrimSpace(lead); lead != "" {
menu.Text = lead + "\n\n" + menu.Text
}
return menu
}
func (s *Service) myBotsSettingsScreen(state *domain.BotChatState, b ownedBot, lead string) botReply {
inlineOn := b.profile.InlinePlaceholder != ""
groupsOn := !b.profile.Nochats
@ -529,9 +623,9 @@ func (s *Service) myBotsSettingsScreen(state *domain.BotChatState, b ownedBot, l
{{text: " Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
}
body := fmt.Sprintf("Settings for @%s. Tap a row to flip it.\n\n"+
"• Inline Mode — %s\n"+
"• Allow Groups — %s (can the bot be added to groups)\n"+
"• Group Privacy — %s (on = only sees commands and replies in groups)",
"- Inline Mode: %s\n"+
"- Allow Groups: %s (can the bot be added to groups)\n"+
"- Group Privacy: %s (on = only sees commands and replies in groups)",
b.user.Username, onOff(inlineOn), onOff(groupsOn), onOff(privacyOn))
if lead != "" {
body = lead + "\n\n" + body