diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 22d6a78a..af1d7c3a 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -682,6 +682,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 } diff --git a/internal/app/bots/mybots.go b/internal/app/bots/mybots.go index bad103aa..5b60d36c 100644 --- a/internal/app/bots/mybots.go +++ b/internal/app/bots/mybots.go @@ -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 diff --git a/internal/app/bots/mybots_botpic_test.go b/internal/app/bots/mybots_botpic_test.go index 60570246..4c283193 100644 --- a/internal/app/bots/mybots_botpic_test.go +++ b/internal/app/bots/mybots_botpic_test.go @@ -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) { diff --git a/internal/app/bots/mybots_test.go b/internal/app/bots/mybots_test.go index 476f0587..17ae5fb3 100644 --- a/internal/app/bots/mybots_test.go +++ b/internal/app/bots/mybots_test.go @@ -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{}} diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index 0a7494be..fe171cad 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -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 diff --git a/internal/app/bots/service_bot_entities.go b/internal/app/bots/service_bot_entities.go index ba341157..17be860c 100644 --- a/internal/app/bots/service_bot_entities.go +++ b/internal/app/bots/service_bot_entities.go @@ -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 diff --git a/internal/app/files/bot_avatar.go b/internal/app/files/bot_avatar.go index bf87b31f..19519464 100644 --- a/internal/app/files/bot_avatar.go +++ b/internal/app/files/bot_avatar.go @@ -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.