feat: sync bot rich messages and inline menus

This commit is contained in:
A 2026-07-21 15:44:43 +08:00
parent 2965f5d47d
commit 1a2d03f529
24 changed files with 2073 additions and 38 deletions

View file

@ -253,7 +253,7 @@ func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 {
return false
}
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
return msg.Body != "" || (msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0) || len(apiMessageMedia(msg.Media, nil, nil)) > 0
}
func apiUser(u domain.User) map[string]any {
@ -320,6 +320,12 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
out["entities"] = entities
}
}
if msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0 {
var richMessage any
if json.Unmarshal(msg.RichMessage.BotAPIProjection, &richMessage) == nil && richMessage != nil {
out["rich_message"] = richMessage
}
}
if msg.EditDate > 0 {
out["edit_date"] = msg.EditDate
}

View file

@ -0,0 +1,99 @@
package botapi
import (
"bytes"
"encoding/json"
"errors"
"strconv"
"strings"
"telesrv/internal/domain"
)
const maxBotAPIRichSourceBytes = 256 << 10
func richMessageInputFromAPI(raw string) (domain.BotAPIRichMessageInput, error) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > maxBotAPIRichSourceBytes {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
var out domain.BotAPIRichMessageInput
sources := 0
if value, ok := fields["html"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
if err := json.Unmarshal(value, &out.HTML); err != nil || out.HTML == "" {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
sources++
}
if value, ok := fields["markdown"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
if err := json.Unmarshal(value, &out.Markdown); err != nil || out.Markdown == "" {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
sources++
}
if value, ok := fields["blocks"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
if len(bytes.TrimSpace(value)) == 0 {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
out.BlocksJSON = append([]byte(nil), value...)
sources++
}
if sources != 1 {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
if len(out.BlocksJSON) != 0 {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_BLOCKS_UNSUPPORTED")
}
if value, ok := fields["media"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) && !bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
out.MediaJSON = append([]byte(nil), value...)
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_MEDIA_UNSUPPORTED")
}
if value, ok := fields["is_rtl"]; ok {
if err := json.Unmarshal(value, &out.RTL); err != nil {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
}
if value, ok := fields["skip_entity_detection"]; ok {
if err := json.Unmarshal(value, &out.SkipEntityDetection); err != nil {
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
}
}
return out, nil
}
func richReplyMessageID(values map[string]string) (int, error) {
legacy := apiInt(values["reply_to_message_id"], 0)
raw := strings.TrimSpace(values["reply_parameters"])
if raw == "" {
if legacy < 0 {
return 0, errors.New("REPLY_MESSAGE_ID_INVALID")
}
return legacy, nil
}
if legacy != 0 {
return 0, errors.New("REPLY_PARAMETERS_INVALID")
}
var payload struct {
MessageID int `json:"message_id"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload.MessageID <= 0 {
return 0, errors.New("REPLY_PARAMETERS_INVALID")
}
return payload.MessageID, nil
}
func apiInt64(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
return 0, errors.New("VALUE_INVALID")
}
return value, nil
}

View file

@ -43,9 +43,12 @@ type GatewayService interface {
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error)
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
BotAPISendRichMessage(ctx context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error)
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error)
BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error)
BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error)
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
@ -204,6 +207,8 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
h.getUpdates(w, r, botID)
case "sendmessage":
h.sendMessage(w, r, botID)
case "sendrichmessage":
h.sendRichMessage(w, r, botID)
case "sendphoto":
h.sendMedia(w, r, botID, "photo")
case "sendanimation":
@ -577,6 +582,71 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
writeAPIOK(w, apiMessage(msg, users))
}
func (h *handler) sendRichMessage(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
if strings.TrimSpace(values["business_connection_id"]) != "" {
writeAPIError(w, http.StatusBadRequest, "BUSINESS_CONNECTION_INVALID")
return
}
if apiInt(values["message_thread_id"], 0) != 0 || apiInt(values["direct_messages_topic_id"], 0) != 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_THREAD_INVALID")
return
}
if apiBool(values["allow_paid_broadcast"]) || strings.TrimSpace(values["suggested_post_parameters"]) != "" {
writeAPIError(w, http.StatusBadRequest, "RICH_MESSAGE_OPTION_UNSUPPORTED")
return
}
rich, err := richMessageInputFromAPI(values["rich_message"])
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
replyTo, err := richReplyMessageID(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
effectID, err := apiInt64(values["message_effect_id"])
if err != nil {
writeAPIError(w, http.StatusBadRequest, "EFFECT_ID_INVALID")
return
}
msg, err := h.gateway.BotAPISendRichMessage(
r.Context(), botID, chatID, rich, markup,
apiBool(values["disable_notification"]), apiBool(values["protect_content"]), replyTo, effectID,
)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
users := []domain.User(nil)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
writeAPIOK(w, apiMessage(msg, users))
}
func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, kind string) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
@ -695,7 +765,26 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
return
}
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
rawRich := strings.TrimSpace(values["rich_message"])
_, textSpecified := values["text"]
if rawRich != "" && textSpecified {
writeAPIError(w, http.StatusBadRequest, "RICH_MESSAGE_INVALID")
return
}
var (
text string
entities []domain.MessageEntity
rich domain.BotAPIRichMessageInput
)
if rawRich != "" {
rich, err = richMessageInputFromAPI(rawRich)
} else {
if !textSpecified {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
return
}
text, entities, err = botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
}
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -715,7 +804,12 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
var ok bool
if rawRich != "" {
ok, err = h.gateway.BotAPIEditInlineRichMessage(r.Context(), botID, inlineID, rich, setReplyMarkup, markup)
} else {
ok, err = h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
}
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
@ -723,7 +817,12 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIOK(w, ok)
return
}
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
var msg domain.Message
if rawRich != "" {
msg, err = h.gateway.BotAPIEditRichMessage(r.Context(), botID, chatID, messageID, rich, setReplyMarkup, markup)
} else {
msg, err = h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
}
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
@ -1338,6 +1437,14 @@ func apiErrorDescription(err error) string {
"RESULT_TYPE_INVALID",
"MESSAGE_EMPTY",
"MESSAGE_TOO_LONG",
"RICH_MESSAGE_INVALID",
"RICH_MESSAGE_TOO_LONG",
"RICH_MESSAGE_DATE_INVALID",
"RICH_MESSAGE_BLOCKS_UNSUPPORTED",
"RICH_MESSAGE_MEDIA_UNSUPPORTED",
"RICH_MESSAGE_OPTION_UNSUPPORTED",
"WEBPAGE_MEDIA_EMPTY",
"EFFECT_ID_INVALID",
"BUTTON_INVALID",
"BUTTON_DATA_INVALID",
"BUTTON_URL_INVALID",

View file

@ -600,6 +600,101 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
}
}
func TestSendRichMessageAndEditPreserveInlineKeyboardAndProjection(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
projection := json.RawMessage(`{"blocks":[{"type":"heading","size":4,"text":"Admin"}],"is_rtl":true}`)
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
}}}}
message := domain.Message{
ID: 21, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000021, Out: true, ReplyMarkup: markup,
RichMessage: &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1}, BotAPIProjection: projection},
}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Bedolaga", Username: "bedolaga_bot", Bot: true},
sendMessage: message,
editMessage: message,
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendRichMessage", `{
"chat_id":2001,
"rich_message":{"html":"<h4>Admin</h4>","is_rtl":true,"skip_entity_detection":true},
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]},
"disable_notification":true,
"protect_content":true,
"reply_parameters":{"message_id":7}
}`)
if rec.Code != http.StatusOK {
t.Fatalf("sendRichMessage status=%d body=%s", rec.Code, rec.Body.String())
}
if !gateway.sendRichCalled || gateway.sendChatID != 2001 || gateway.sendRichInput.HTML != "<h4>Admin</h4>" ||
!gateway.sendRichInput.RTL || !gateway.sendRichInput.SkipEntityDetection || !gateway.sendSilent || gateway.sendReplyTo != 7 {
t.Fatalf("send rich call = %#v", gateway)
}
if gateway.sendRichMarkup == nil || len(gateway.sendRichMarkup.Inline) != 1 ||
string(gateway.sendRichMarkup.Inline[0][0].Data) != "menu:info" {
t.Fatalf("send rich markup = %#v", gateway.sendRichMarkup)
}
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
gateway.editMessage.RichMessage.BotAPIProjection = json.RawMessage(`{"blocks":[{"type":"paragraph","text":"Updated"}]}`)
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
"chat_id":2001,
"message_id":21,
"rich_message":{"markdown":"**Updated**","skip_entity_detection":true},
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]}
}`)
if rec.Code != http.StatusOK {
t.Fatalf("editMessageText rich status=%d body=%s", rec.Code, rec.Body.String())
}
if !gateway.editRichCalled || gateway.editRichInput.Markdown != "**Updated**" || !gateway.editRichInput.SkipEntityDetection || !gateway.editSetMarkup {
t.Fatalf("edit rich call = %#v", gateway)
}
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
}
func TestEditMessageTextRejectsTextAndRichMessageTogether(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
"chat_id":2001,"message_id":21,"text":"plain","rich_message":{"html":"<p>rich</p>"}
}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "RICH_MESSAGE_INVALID") {
t.Fatalf("edit text+rich status=%d body=%s", rec.Code, rec.Body.String())
}
}
func assertBotAPIRichMenuResponse(t *testing.T, raw []byte, messageID int) {
t.Helper()
var response struct {
OK bool `json:"ok"`
Result struct {
MessageID int `json:"message_id"`
RichMessage struct {
Blocks []struct {
Type string `json:"type"`
} `json:"blocks"`
} `json:"rich_message"`
ReplyMarkup struct {
InlineKeyboard [][]struct {
CallbackData string `json:"callback_data"`
} `json:"inline_keyboard"`
} `json:"reply_markup"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatalf("decode rich response: %v", err)
}
if !response.OK || response.Result.MessageID != messageID || len(response.Result.RichMessage.Blocks) != 1 ||
len(response.Result.ReplyMarkup.InlineKeyboard) != 1 || response.Result.ReplyMarkup.InlineKeyboard[0][0].CallbackData != "menu:info" {
t.Fatalf("rich response = %s", raw)
}
}
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
markup := &domain.MessageReplyMarkup{
@ -1329,6 +1424,9 @@ type fakeBotAPIGateway struct {
sendSilent bool
sendReplyTo int
sendMessage domain.Message
sendRichCalled bool
sendRichInput domain.BotAPIRichMessageInput
sendRichMarkup *domain.MessageReplyMarkup
sendMediaCalled bool
sendMediaKind string
sendMediaChatID int64
@ -1342,6 +1440,8 @@ type fakeBotAPIGateway struct {
editEntities []domain.MessageEntity
editSetMarkup bool
editMessage domain.Message
editRichCalled bool
editRichInput domain.BotAPIRichMessageInput
editInlineCalled bool
editInlineID domain.BotInlineMessageID
editInlineText string
@ -1448,6 +1548,17 @@ func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID i
return f.sendMessage, nil
}
func (f *fakeBotAPIGateway) BotAPISendRichMessage(_ context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
f.sendRichCalled = true
f.sendBotID = botID
f.sendChatID = chatID
f.sendRichInput = rich
f.sendRichMarkup = replyMarkup
f.sendSilent = silent
f.sendReplyTo = replyToMessageID
return f.sendMessage, nil
}
func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
f.sendMediaCalled = true
f.sendMediaKind = kind
@ -1467,6 +1578,13 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditRichMessage(_ context.Context, botID, chatID int64, messageID int, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
f.editRichCalled = true
f.editRichInput = rich
f.editSetMarkup = setReplyMarkup
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
f.editInlineCalled, f.editInlineID = true, inlineMessageID
f.editInlineText = text
@ -1474,6 +1592,12 @@ func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIEditInlineRichMessage(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, rich domain.BotAPIRichMessageInput, _ bool, _ *domain.MessageReplyMarkup) (bool, error) {
f.editInlineCalled, f.editInlineID = true, inlineMessageID
f.editRichInput = rich
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
f.deleteCalled = true
return true, nil