/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 165f81414c
commit 8bc54fbfc5
7 changed files with 215 additions and 10 deletions

View file

@ -678,6 +678,11 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
}
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
}

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

View file

@ -15,6 +15,11 @@ type fakeBotAvatar struct {
sourcePhotoID int64
calls int
err error
hasAvatar bool
}
func (f *fakeBotAvatar) PeerHasAvatar(_ context.Context, _ domain.PeerType, _ int64) (bool, error) {
return f.hasAvatar, nil
}
func (f *fakeBotAvatar) SetAvatarFromExistingPhoto(_ context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, _ int) (domain.Photo, error) {

View file

@ -83,6 +83,15 @@ func mybotsHasButton(msg domain.Message, label string) bool {
return ok
}
func hasMentionEntity(msg domain.Message, mention string) bool {
for _, e := range msg.Entities {
if e.Type == domain.MessageEntityMention && e.Length == len(mention) {
return true
}
}
return false
}
// pressBotFather clicks the button whose text contains label on the user's latest
// @BotFather reply and returns the callback answer plus the user's new latest
// reply (which, for an in-place edit, is the same message updated).
@ -328,6 +337,51 @@ func (d *deletableBotStore) ListBotsByOwner(ctx context.Context, ownerUserID int
return out, nil
}
func TestMyBotsEditBotShowsSummaryAndReturnsAfterEdit(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+2010")
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Enigma Network", "enigma_mb_bot"); err != nil {
t.Fatalf("create bot: %v", err)
}
sendToBotFather(t, svc, messages, owner, "/mybots")
pressBotFather(t, svc, messages, owner.ID, "@enigma_mb_bot")
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
for _, want := range []string{"Edit @enigma_mb_bot info.", "Name: Enigma Network", "About: 🚫", "Commands: no commands yet", "Botpic: 🚫"} {
if !strings.Contains(edit.Body, want) {
t.Fatalf("edit summary missing %q:\n%s", want, edit.Body)
}
}
for _, want := range []string{"Edit Name", "Edit About", "Edit Botpic", "Back to bot", "Bots list"} {
if !mybotsHasButton(edit, want) {
t.Fatalf("edit menu missing button %q: %+v", want, edit.ReplyMarkup)
}
}
// The @mention is a tappable entity, not plain text.
if !hasMentionEntity(edit, "@enigma_mb_bot") {
t.Fatalf("no mention entity for @enigma_mb_bot: %+v", edit.Entities)
}
pressBotFather(t, svc, messages, owner.ID, "Edit About")
sendToBotFather(t, svc, messages, owner, "we host things")
after := botFatherUserReply(t, messages, owner.ID)
if !strings.Contains(after.Body, "About section updated") || !strings.Contains(after.Body, "Edit @enigma_mb_bot info.") {
t.Fatalf("post-edit reply is not the edit menu:\n%s", after.Body)
}
if !strings.Contains(after.Body, "About: we host things") {
t.Fatalf("edit menu did not refresh the About value:\n%s", after.Body)
}
// The bug this guards: pressing another field on the refreshed menu must work,
// not report the button as expired.
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Botpic")
if answer.Alert {
t.Fatalf("Edit Botpic on the refreshed menu alerted: %q", answer.Message)
}
if !strings.Contains(prompt.Body, "profile picture") {
t.Fatalf("Edit Botpic prompt = %q", prompt.Body)
}
}
func TestMyBotsDeleteBot(t *testing.T) {
users := memory.NewUserStore()
store := &deletableBotStore{BotStore: memory.NewBotStore(users), deleted: map[int64]bool{}}

View file

@ -82,6 +82,7 @@ type verificationApplications interface {
// because a service bot must not reach into the file layer for anything else.
type botAvatarStore interface {
SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error)
PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error)
}
// The third-party verification ports live in verifierbot.go

View file

@ -45,6 +45,10 @@ func serviceBotReplyEntities(text string, explicit []domain.MessageEntity) []dom
offset, length := utf16Range(text, span.start, span.end)
appendEntity(domain.MessageEntity{Type: domain.MessageEntityBotCommand, Offset: offset, Length: length})
}
for _, span := range serviceBotMentionByteSpans(text) {
offset, length := utf16Range(text, span.start, span.end)
appendEntity(domain.MessageEntity{Type: domain.MessageEntityMention, Offset: offset, Length: length})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Offset != out[j].Offset {
return out[i].Offset < out[j].Offset
@ -138,6 +142,42 @@ func serviceBotCommandByteSpans(text string) []serviceBotEntitySpan {
return spans
}
// serviceBotMentionByteSpans finds "@username" runs so a service bot's messages
// render the mention as a tappable link. A username is 5-32 of [A-Za-z0-9_]; the
// "@" must not sit right after a word character (so "you@example" is not a
// mention) and the run must not be followed by one.
func serviceBotMentionByteSpans(text string) []serviceBotEntitySpan {
var spans []serviceBotEntitySpan
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
if r != '@' || !serviceBotCommandStart(text, i) {
i += size
continue
}
start := i
i += size
nameStart := i
for i < len(text) {
c, csize := utf8.DecodeRuneInString(text[i:])
if !serviceBotCommandChar(c) {
break
}
i += csize
}
nameLen := i - nameStart
if nameLen < 5 || nameLen > 32 {
continue
}
if i < len(text) {
if next, _ := utf8.DecodeRuneInString(text[i:]); serviceBotCommandChar(next) {
continue
}
}
spans = append(spans, serviceBotEntitySpan{start: start, end: i})
}
return spans
}
func serviceBotCommandStart(text string, byteIndex int) bool {
if byteIndex == 0 {
return true

View file

@ -50,6 +50,12 @@ func (s *Service) SetAvatarFromExistingPhoto(ctx context.Context, ownerType doma
return photo, nil
}
// PeerHasAvatar reports whether ownerType/ownerID has a current profile photo.
func (s *Service) PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error) {
_, ok, err := s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
return ok, err
}
// photoSourceBytes reads the original image bytes behind a stored photo. Every
// static size of a photo written by putPhotoStaticSizes points at the same
// stored object, so any one size yields the full image.