feat: sync bot api formatted text parse modes

Sync telesrv 00630bc (feat(botapi): support formatted text parse modes).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-20 20:23:47 +08:00
parent afc73ca761
commit 2965f5d47d
11 changed files with 1684 additions and 85 deletions

View file

@ -6,7 +6,6 @@ import (
"net/http"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
@ -219,39 +218,19 @@ func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, b
input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
switch mode {
case "text":
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
if values["text"] == "" {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
return
}
if !utf8.ValidString(values["text"]) || utf8.RuneCountInString(values["text"]) > domain.MaxMessageTextLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
entities, err := botAPIMessageEntities(values["entities"])
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["text"], entities
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities
case "caption":
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["caption"], entities
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
case "reply_markup":
input.Fields.SetReplyMarkup = true
case "media":
@ -290,7 +269,7 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput)
Title string `json:"title"`
Performer string `json:"performer"`
}
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" || strings.TrimSpace(media.ParseMode) != "" {
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" {
return errors.New("MEDIA_INVALID")
}
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true}
@ -316,14 +295,11 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput)
}
input.SecondaryFile = secondary
}
entities, err := botAPIMessageEntities(string(media.CaptionEntities))
caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false)
if err != nil {
return err
}
if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength {
return errors.New("MESSAGE_TOO_LONG")
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.Caption, entities
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,255 @@
package botapi
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"unicode/utf8"
"telesrv/internal/domain"
)
func TestParseBotAPIHTMLNestedUTF16LinksAndDate(t *testing.T) {
plain, entities, err := parseBotAPIHTML(`<b>A <i>😀</i></b> <a href="tg://user?id=42">Alice</a> <tg-time unix="1700000000" format="wdT">now</tg-time>`)
if err != nil {
t.Fatal(err)
}
if plain != "A 😀 Alice now" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
{Type: domain.MessageEntityItalic, Offset: 2, Length: 2},
{Type: domain.MessageEntityMentionName, Offset: 5, Length: 5, UserID: 42},
{Type: domain.MessageEntityFormattedDate, Offset: 11, Length: 3, Date: 1700000000, DayOfWeek: true, ShortDate: true, LongTime: true},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPIHTMLPreAndEscapes(t *testing.T) {
plain, entities, err := parseBotAPIHTML(`<pre><code class="language-go">if a &lt; b &amp;&amp; b &gt; c</code></pre>`)
if err != nil {
t.Fatal(err)
}
if plain != "if a < b && b > c" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{{Type: domain.MessageEntityPre, Offset: 0, Length: 17, Language: "go"}}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPILegacyMarkdown(t *testing.T) {
plain, entities, err := parseBotAPIMarkdown(`*bold* _😀_ [site](https://example.com) \*raw\*`)
if err != nil {
t.Fatal(err)
}
if plain != "bold 😀 site *raw*" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com"},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestParseBotAPIMarkdownV2NestedLinksAndExpandableQuote(t *testing.T) {
plain, entities, err := parseBotAPIMarkdownV2(`*bold _😀_* [site](https://example.com/a\)b) ||secret||`)
if err != nil {
t.Fatal(err)
}
if plain != "bold 😀 site secret" {
t.Fatalf("plain = %q", plain)
}
want := []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 7},
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com/a)b"},
{Type: domain.MessageEntitySpoiler, Offset: 13, Length: 6},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
plain, entities, err = parseBotAPIMarkdownV2(">visible\n>hidden||")
if err != nil {
t.Fatal(err)
}
if plain != "visible\nhidden" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBlockquote, Offset: 0, Length: 14, Collapsed: true}}) {
t.Fatalf("expandable quote plain=%q entities=%#v", plain, entities)
}
}
func TestParseBotAPIMarkdownV2FormattedDate(t *testing.T) {
plain, entities, err := parseBotAPIMarkdownV2(`![when](tg://time?unix=1700000000&format=wdT)`)
if err != nil {
t.Fatal(err)
}
want := []domain.MessageEntity{{
Type: domain.MessageEntityFormattedDate, Offset: 0, Length: 4, Date: 1700000000,
DayOfWeek: true, ShortDate: true, LongTime: true,
}}
if plain != "when" || !reflect.DeepEqual(entities, want) {
t.Fatalf("plain=%q entities=%#v", plain, entities)
}
}
func TestBotAPIFormattedTextPrecedenceAndEntityBounds(t *testing.T) {
plain, entities, err := botAPIFormattedTextRaw(`<b>ok</b>`, " HTML ", `{not json`, domain.MaxMessageTextLength, true)
if err != nil {
t.Fatal(err)
}
if plain != "ok" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 2}}) {
t.Fatalf("plain=%q entities=%#v", plain, entities)
}
for name, raw := range map[string]string{
"unterminated HTML": `<b>broken`,
"reserved MarkdownV2": `plain-text`,
} {
t.Run(name, func(t *testing.T) {
mode := "HTML"
if strings.Contains(name, "MarkdownV2") {
mode = "MarkdownV2"
}
if _, _, err := botAPIFormattedTextRaw(raw, mode, "", domain.MaxMessageTextLength, true); err == nil || !strings.Contains(err.Error(), "Can't parse entities") {
t.Fatalf("error = %v", err)
}
})
}
_, _, err = botAPIFormattedText("😀x", "", []apiMessageEntity{{Type: "bold", Offset: 1, Length: 1}}, domain.MaxMessageTextLength, true)
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
t.Fatalf("surrogate-split error = %v", err)
}
_, _, err = botAPIFormattedText("abcdef", "", []apiMessageEntity{
{Type: "bold", Offset: 0, Length: 4},
{Type: "italic", Offset: 2, Length: 4},
}, domain.MaxMessageTextLength, true)
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
t.Fatalf("crossing error = %v", err)
}
}
func TestBotAPIExplicitExtendedEntitiesRoundTrip(t *testing.T) {
input := []apiMessageEntity{
{Type: "expandable_blockquote", Offset: 0, Length: 4},
{Type: "date_time", Offset: 5, Length: 4, UnixTime: 1700000000, DateTimeFormat: "wdT"},
{Type: "bank_card_number", Offset: 10, Length: 4},
}
_, entities, err := botAPIFormattedText("text when 1234", "", input, domain.MaxMessageTextLength, true)
if err != nil {
t.Fatal(err)
}
projected := apiMessageEntities(entities, nil)
if projected[0]["type"] != "expandable_blockquote" || projected[1]["type"] != "date_time" || projected[1]["unix_time"] != 1700000000 || projected[1]["date_time_format"] != "wdT" || projected[2]["type"] != "bank_card_number" {
t.Fatalf("projected = %#v", projected)
}
}
func TestBotAPIInlineAndNestedMediaUseFormattedTextParser(t *testing.T) {
payload := apiInlineResult{InputMessageContent: json.RawMessage(`{
"message_text":"<b>inline</b>",
"parse_mode":"HTML",
"entities":[{"type":"bold","offset":999,"length":1}]
}`)}
message, entities, _, err := inputTextMessageContentFromAPI(payload)
if err != nil {
t.Fatal(err)
}
if message != "inline" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 6}}) {
t.Fatalf("inline message=%q entities=%#v", message, entities)
}
fileID := encodeBotAPIFileID("photo:7002:m")
raw, _ := json.Marshal(map[string]any{
"type": "photo", "media": fileID, "caption": "_media_", "parse_mode": "MarkdownV2",
})
var input domain.BotAPIEphemeralEditInput
if err := parseEphemeralEditMedia(string(raw), &input); err != nil {
t.Fatal(err)
}
if input.Fields.Message != "media" || !reflect.DeepEqual(input.Fields.Entities, []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}) {
t.Fatalf("media fields=%#v", input.Fields)
}
}
func TestBotAPIFormattedTextIsUsedByAllMessageEntryPoints(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
sendMessage: domain.Message{ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "hello"},
sendMediaMessage: domain.Message{ID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "caption"},
editMessage: domain.Message{ID: 3, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "edited"},
ephemeralMessage: domain.EphemeralMessage{ID: 4, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, Content: domain.EphemeralContent{Message: "ephemeral"}},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>hello</b>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || gateway.sendText != "hello" || len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("sendMessage status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendText, gateway.sendEntities)
}
fileID := encodeBotAPIFileID("doc:7001")
body, _ := json.Marshal(map[string]any{"chat_id": 2001, "document": fileID, "caption": "*caption*", "parse_mode": "MarkdownV2"})
rec = performBotAPIRequest(t, h, bots.profile, "sendDocument", string(body))
if rec.Code != http.StatusOK || gateway.sendMediaCaption != "caption" || len(gateway.sendMediaEntities) != 1 || gateway.sendMediaEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("sendDocument status=%d body=%s caption=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendMediaCaption, gateway.sendMediaEntities)
}
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":3,"text":"_edited_","parse_mode":"Markdown"}`)
if rec.Code != http.StatusOK || gateway.editText != "edited" || len(gateway.editEntities) != 1 || gateway.editEntities[0].Type != domain.MessageEntityItalic {
t.Fatalf("edit status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.editText, gateway.editEntities)
}
rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"text":"<u>ephemeral</u>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || len(gateway.ephemeralSends) == 0 {
t.Fatalf("ephemeral status=%d body=%s", rec.Code, rec.Body.String())
}
lastSend := gateway.ephemeralSends[len(gateway.ephemeralSends)-1]
if lastSend.Text != "ephemeral" || len(lastSend.Entities) != 1 || lastSend.Entities[0].Type != domain.MessageEntityUnderline {
t.Fatalf("ephemeral status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastSend)
}
rec = performBotAPIRequest(t, h, bots.profile, "editEphemeralMessageCaption", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":4,"caption":"<s>caption</s>","parse_mode":"HTML"}`)
if rec.Code != http.StatusOK || len(gateway.ephemeralEdits) == 0 {
t.Fatalf("ephemeral edit status=%d body=%s", rec.Code, rec.Body.String())
}
lastEdit := gateway.ephemeralEdits[len(gateway.ephemeralEdits)-1]
if lastEdit.Fields.Message != "caption" || len(lastEdit.Fields.Entities) != 1 || lastEdit.Fields.Entities[0].Type != domain.MessageEntityStrike {
t.Fatalf("ephemeral edit status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastEdit)
}
}
func FuzzBotAPIFormattedTextParsersNeverPanic(f *testing.F) {
for _, seed := range []string{"", "plain", "<b>x</b>", "<", "&broken", "*x*", "_", ">quote\n>hidden||", "![x](tg://emoji?id=1)", "😀"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
if len(input) > 4096 || !utf8.ValidString(input) {
return
}
_, _, _ = parseBotAPIHTML(input)
_, _, _ = parseBotAPIMarkdown(input)
_, _, _ = parseBotAPIMarkdownV2(input)
})
}
func TestBotAPIHTMLParseFailureIsAtomic(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>broken","parse_mode":"HTML"}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Can't parse entities") || gateway.sendCalled {
t.Fatalf("status=%d body=%s gatewayCalled=%v", rec.Code, rec.Body.String(), gateway.sendCalled)
}
}

View file

@ -6,7 +6,6 @@ import (
"net/url"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M
} else if payload.MessageText != "" {
content.MessageText = payload.MessageText
}
if content.ParseMode != "" {
return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED")
}
message := content.MessageText
if message == "" {
return "", nil, false, errors.New("MESSAGE_EMPTY")
}
if utf8.RuneCountInString(message) > domain.MaxMessageTextLength {
return "", nil, false, errors.New("MESSAGE_TOO_LONG")
}
entities, err := messageEntitiesFromAPI(content.Entities)
message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true)
if err != nil {
return "", nil, false, err
}
@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item := domain.MessageEntity{
Type: mapped,
Offset: entity.Offset,
Length: entity.Length,
URL: entity.URL,
Language: entity.Language,
Type: mapped,
Offset: entity.Offset,
Length: entity.Length,
}
if entity.User != nil {
item.UserID = entity.User.ID
}
if entity.CustomEmojiID != "" {
switch mapped {
case domain.MessageEntityTextURL:
resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length)
if !ok {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item = resolved
case domain.MessageEntityMentionName:
if entity.User != nil {
item.UserID = entity.User.ID
}
if item.UserID <= 0 {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
case domain.MessageEntityPre:
item.Language = entity.Language
case domain.MessageEntityBlockquote:
item.Collapsed = entity.Type == "expandable_blockquote"
case domain.MessageEntityCustomEmoji:
id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
if err != nil || id <= 0 {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
item.DocumentID = id
case domain.MessageEntityFormattedDate:
formatted, err := botAPIFormattedDate(entity.UnixTime, entity.DateTimeFormat)
if err != nil {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
}
formatted.Offset, formatted.Length = entity.Offset, entity.Length
item = formatted
}
out = append(out, item)
}
@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntitySpoiler, true
case "blockquote":
return domain.MessageEntityBlockquote, true
case "expandable_blockquote":
return domain.MessageEntityBlockquote, true
case "custom_emoji":
return domain.MessageEntityCustomEmoji, true
case "mention":
@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntityEmail, true
case "phone_number":
return domain.MessageEntityPhone, true
case "bank_card_number":
return domain.MessageEntityBankCard, true
case "date_time":
return domain.MessageEntityFormattedDate, true
default:
return "", false
}
@ -450,8 +465,10 @@ type apiMessageEntity struct {
User *struct {
ID int64 `json:"id"`
} `json:"user"`
Language string `json:"language"`
CustomEmojiID string `json:"custom_emoji_id"`
Language string `json:"language"`
CustomEmojiID string `json:"custom_emoji_id"`
UnixTime int `json:"unix_time"`
DateTimeFormat string `json:"date_time_format"`
}
type apiInlineKeyboardMarkup struct {

View file

@ -402,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
"offset": entity.Offset,
"length": entity.Length,
}
if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed {
item["type"] = "expandable_blockquote"
}
if entity.URL != "" {
item["url"] = entity.URL
}
@ -418,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
if entity.DocumentID != 0 {
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10)
}
if entity.Type == domain.MessageEntityFormattedDate {
item["unix_time"] = entity.Date
item["date_time_format"] = botAPIFormattedDateFormat(entity)
}
out = append(out, item)
}
return out
@ -461,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) {
return "email", true
case domain.MessageEntityPhone:
return "phone_number", true
case domain.MessageEntityBankCard:
return "bank_card_number", true
case domain.MessageEntityFormattedDate:
return "date_time", true
default:
return "", false
}

View file

@ -15,7 +15,6 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"go.uber.org/zap"
@ -525,16 +524,7 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
text := values["text"]
if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -602,19 +592,11 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
@ -662,7 +644,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities,
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: caption, Entities: entities,
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
})
if err != nil {
@ -673,7 +655,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
return
}
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, caption, entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
@ -713,11 +695,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@ -737,7 +715,7 @@ 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, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
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
@ -745,7 +723,7 @@ 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, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
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
@ -1366,7 +1344,6 @@ func apiErrorDescription(err error) string {
"BOT_INVALID",
"CHAT_ID_INVALID",
"ENTITY_INVALID",
"ENTITY_PARSE_UNSUPPORTED",
"ENTITIES_TOO_LONG",
"ENTITY_BOUNDS_INVALID",
"ENTITY_TYPE_UNSUPPORTED",

View file

@ -1335,12 +1335,17 @@ type fakeBotAPIGateway struct {
sendMediaFileName string
sendMediaBytes []byte
sendMediaCaption string
sendMediaEntities []domain.MessageEntity
sendMediaMessage domain.Message
editCalled bool
editText string
editEntities []domain.MessageEntity
editSetMarkup bool
editMessage domain.Message
editInlineCalled bool
editInlineID domain.BotInlineMessageID
editInlineText string
editInlineEntities []domain.MessageEntity
deleteCalled bool
callbackCalled bool
callbackID string
@ -1450,17 +1455,22 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int
f.sendMediaFileName = fileName
f.sendMediaBytes = append([]byte(nil), fileBytes...)
f.sendMediaCaption = caption
f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...)
return f.sendMediaMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
f.editCalled = true
f.editText = text
f.editEntities = append([]domain.MessageEntity(nil), entities...)
f.editSetMarkup = setReplyMarkup
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
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
f.editInlineEntities = append([]domain.MessageEntity(nil), entities...)
return true, nil
}