/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:
parent
f474a360d7
commit
5afafc60c4
7 changed files with 215 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue