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

@ -14,6 +14,13 @@ telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legac
MarkdownV2用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
TDesktop` 链路。
`/richdemo` 进一步复刻 Bedolaga 的 rich menu调用 `sendRichMessage` 发送 HTML 与
Markdown `InputRichMessage`,携带 inline callback keyboard再通过
`editMessageText.rich_message` 编辑 HTML 菜单。HTML 样例覆盖 heading、divider、
bordered/striped table、`tg-time`、details、blockquote、code 与 footer。第一次请求
故意带远程 logo当前本地 blob backend 返回 `WEBPAGE_MEDIA_EMPTY`demo 按
Bedolaga 的既有策略自动去掉 logo 重试,正文与按钮不会降级成 classic menu。
## 安装
建议使用虚拟环境token 只通过环境变量传入:
@ -34,6 +41,7 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
```text
/start
/formatdemo
/richdemo
```
也可以不启动 polling直接向指定私聊发送三条格式测试消息
@ -46,5 +54,16 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
--marker BEDOLAGA-LOCAL-VERIFY
```
只主动验证 rich menuHTML + Markdown + 按钮 + 编辑 + logo fallback
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py `
--send-only `
--rich-only `
--send-chat-id 1780243200 `
--marker BEDOLAGA-RICH-VERIFY
```
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token也不会
把 token 写入文件。

View file

@ -21,8 +21,14 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
InputRichMessage,
Message,
)
LOG = logging.getLogger("bedolagaformat")
@ -101,6 +107,16 @@ def parse_args() -> argparse.Namespace:
help="Send the complete suite proactively before polling",
)
parser.add_argument("--send-only", action="store_true")
parser.add_argument(
"--rich-menu",
action="store_true",
help="also send and edit Bedolaga-style rich HTML/Markdown menus",
)
parser.add_argument(
"--rich-only",
action="store_true",
help="with --send-only, send only the rich menu suite",
)
parser.add_argument("--drop-pending", action="store_true")
parser.add_argument("--polling-timeout", type=int, default=10)
parser.add_argument("--marker", default=default_marker())
@ -110,6 +126,8 @@ def parse_args() -> argparse.Namespace:
parser.error("missing --token or TELESRV_BOT_TOKEN")
if args.send_only and args.send_chat_id is None:
parser.error("--send-only requires --send-chat-id")
if args.rich_only and (not args.send_only or args.send_chat_id is None):
parser.error("--rich-only requires --send-only and --send-chat-id")
if not MARKER_RE.fullmatch(args.marker):
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
if not 0 <= args.polling_timeout <= 50:
@ -151,6 +169,96 @@ async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
return message_ids
def rich_menu_html(marker: str, *, include_logo: bool) -> str:
"""Build the rich HTML families used by Bedolaga's main menu."""
logo = '<img src="https://example.com/bedolaga-logo.png">' if include_logo else ""
return (
f"{logo}<h4>{marker} Admin</h4>"
"<h6>Subscription overview</h6><hr>"
"<table bordered striped>"
"<tr><th>Status</th><td align=\"right\">Active</td></tr>"
"<tr><th>Updated</th><td align=\"right\">"
'<tg-time unix="1700000000" format="R">now</tg-time>'
"</td></tr></table>"
"<details open><summary>Diagnostics</summary>"
"<blockquote><code>rich menu online</code></blockquote></details>"
"<footer>Choose an option</footer>"
)
def rich_menu_markdown(marker: str) -> str:
return (
f"#### {marker} Markdown menu\n\n"
"**Subscription:** Active\n\n"
"> Rich Markdown transport is online.\n\n"
"`callback keyboard preserved`"
)
def rich_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="Balance", callback_data="menu:balance"),
InlineKeyboardButton(text="Buy", callback_data="menu:buy"),
],
[InlineKeyboardButton(text="Info", callback_data="menu:info")],
]
)
def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool:
message = str(exc).lower()
return "webpage_" in message or "media_empty" in message or "media_invalid" in message
async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
"""Exercise Bedolaga's send, no-logo retry, keyboard and rich edit path."""
markup = rich_menu_keyboard()
try:
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=True),
skip_entity_detection=True,
),
reply_markup=markup,
)
except TelegramBadRequest as exc:
if not is_rich_media_retry_error(exc):
raise
LOG.info("rich logo fetch rejected; retrying the menu without logo")
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
markdown_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
markdown=rich_menu_markdown(marker),
skip_entity_detection=True,
),
reply_markup=markup,
)
await bot.edit_message_text(
chat_id=chat_id,
message_id=html_message.message_id,
rich_message=InputRichMessage(
html=rich_menu_html(f"{marker} EDITED", include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
ids = [html_message.message_id, markdown_message.message_id]
LOG.info("sent rich menu suite chat_id=%s message_ids=%s", chat_id, ids)
return ids
def build_dispatcher(marker: str) -> Dispatcher:
router = Router(name="telesrv-bedolaga-format")
@ -173,6 +281,16 @@ def build_dispatcher(marker: str) -> Dispatcher:
ids,
)
@router.message(Command("richdemo"))
async def rich_demo(message: Message) -> None:
ids = await send_rich_suite(message.bot, message.chat.id, marker)
LOG.info(
"handled /richdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
message.chat.id,
message.message_id,
ids,
)
dispatcher = Dispatcher()
dispatcher.include_router(router)
return dispatcher
@ -190,13 +308,16 @@ async def run(args: argparse.Namespace) -> None:
args.marker,
)
if args.send_chat_id is not None:
if not args.rich_only:
await send_format_suite(bot, args.send_chat_id, args.marker)
if args.rich_menu or args.rich_only:
await send_rich_suite(bot, args.send_chat_id, args.marker)
if args.send_only:
return
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
dispatcher = build_dispatcher(args.marker)
LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id)
LOG.info("polling started; send /start, /formatdemo or /richdemo to @%s", me.username or me.id)
await dispatcher.start_polling(
bot,
allowed_updates=["message"],

View file

@ -5,6 +5,9 @@ import unittest
from unittest.mock import AsyncMock
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendRichMessage
from aiogram.types import InputRichMessage
MODULE_PATH = Path(__file__).with_name("demo.py")
@ -47,6 +50,45 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
def test_rich_menu_covers_bedolaga_html_and_keyboard(self) -> None:
html = demo.rich_menu_html("BEDOLAGA123", include_logo=False)
self.assertIn("<h4>BEDOLAGA123 Admin</h4>", html)
self.assertIn("<table bordered striped>", html)
self.assertIn("<tg-time", html)
self.assertIn("<details open>", html)
self.assertIn("<footer>", html)
markup = demo.rich_menu_keyboard()
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "menu:balance")
self.assertEqual(markup.inline_keyboard[1][0].callback_data, "menu:info")
async def test_rich_suite_retries_without_logo_and_edits(self) -> None:
bot = AsyncMock()
media_error = TelegramBadRequest(
method=SendRichMessage(
chat_id=1780243200,
rich_message=InputRichMessage(html="<p>fixture</p>"),
),
message="WEBPAGE_MEDIA_EMPTY",
)
bot.send_rich_message.side_effect = [
media_error,
SentMessage(21),
SentMessage(22),
]
bot.edit_message_text.return_value = SentMessage(21)
ids = await demo.send_rich_suite(bot, 1780243200, "BEDOLAGA123")
self.assertEqual(ids, [21, 22])
sends = bot.send_rich_message.await_args_list
self.assertEqual(len(sends), 3)
self.assertIn("<img", sends[0].kwargs["rich_message"].html)
self.assertNotIn("<img", sends[1].kwargs["rich_message"].html)
self.assertIsNotNone(sends[2].kwargs["rich_message"].markdown)
edit = bot.edit_message_text.await_args
self.assertEqual(edit.kwargs["message_id"], 21)
self.assertIn("EDITED", edit.kwargs["rich_message"].html)
if __name__ == "__main__":
unittest.main()

View file

@ -402,6 +402,7 @@ func cloneDialogMessages(in []domain.Message) []domain.Message {
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.RichMessage = cloneRichMessage(msg.RichMessage)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
@ -424,6 +425,7 @@ func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMess
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.RichMessage = cloneRichMessage(msg.RichMessage)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)

View file

@ -946,6 +946,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
clone.Blocks = append([]byte(nil), m.Blocks...)
clone.Photos = append([]domain.Photo(nil), m.Photos...)
clone.Documents = append([]domain.Document(nil), m.Documents...)
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
return &clone
}

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

View file

@ -0,0 +1,29 @@
package domain
// BotAPIRichMessageInput is the protocol-neutral HTTP Bot API input passed to
// the RPC edge. Exactly one of HTML, Markdown, or BlocksJSON must be present.
// BlocksJSON and MediaJSON are retained in the DTO so unsupported Bot API 10.2
// shapes are rejected explicitly at the conversion boundary instead of being
// flattened or silently dropped.
type BotAPIRichMessageInput struct {
HTML string
Markdown string
BlocksJSON []byte
MediaJSON []byte
RTL bool
SkipEntityDetection bool
}
func (m BotAPIRichMessageInput) SourceCount() int {
n := 0
if m.HTML != "" {
n++
}
if m.Markdown != "" {
n++
}
if len(m.BlocksJSON) != 0 {
n++
}
return n
}

View file

@ -643,7 +643,7 @@ type ChannelMessage struct {
Reactions *ChannelMessageReactions
Action *ChannelMessageAction
Media *MessageMedia
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
// FromBoostsApplied 是发送时的 sender boost 数快照message.from_boosts_applied
FromBoostsApplied int

View file

@ -154,7 +154,7 @@ type Message struct {
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
@ -165,15 +165,14 @@ type Message struct {
SavedPeer Peer
}
// MessageRichMessage 是 Layer 227 富文本消息richMessage的协议中立快照一组 IV
// MessageRichMessage 是 Layer 228 富文本消息richMessage的协议中立快照一组 IV
// PageBlockBlocks+ 内嵌已解析的 Photos/Documents。
//
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在
// domain 逐类型建模rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化domain 不依赖 tg
// 与 message media 同理Photos/Documents 存已解析快照(含 viewer 无关的 access_hash
// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessageblocks 形态),不解析
// HTML/Markdown 变体。
// 投影复用 tgPhoto/tgDocument。HTML/Markdown 输入也会在 RPC 边界归一为同一组 Blocks。
//
// 已知局限Blocks 是 gotd 线格式不透明字节,跨 gotd 版本PageBlock 构造器变更)可能
// 失效——富文本消息为全新实验特性、无存量数据Phase 1 接受该耦合。
@ -183,6 +182,10 @@ type MessageRichMessage struct {
Blocks []byte `json:"blocks,omitempty"`
Photos []Photo `json:"photos,omitempty"`
Documents []Document `json:"documents,omitempty"`
// BotAPIProjection 是由 RPC 边界从同一组已校验 PageBlock 派生出的
// Bot API RichMessage JSON。它不是第二事实源写入边界只允许从 Blocks
// 生成HTTP Bot API 投影只读,避免 botapi 包反向依赖 tg 类型。
BotAPIProjection []byte `json:"bot_api_projection,omitempty"`
}
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message
@ -289,7 +292,7 @@ type SendPrivateTextRequest struct {
BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
}

View file

@ -200,7 +200,7 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, replyMarkup, silent, reply)
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, nil, replyMarkup, silent, false, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
@ -229,6 +229,66 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
return res.SenderMessage, nil
}
// BotAPISendRichMessage sends one durable rich message through the same
// private/channel state machines as messages.sendMessage. The HTTP input is
// parsed into canonical PageBlocks before any message row, pts or outbox entry
// is written.
func (r *Router) BotAPISendRichMessage(ctx context.Context, botID, chatID int64, input domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
if r == nil || botID == 0 {
return domain.Message{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(chatID)
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if effectID != 0 && (peer.Type != domain.PeerTypeUser || r.messageEffectInvalid(ctx, effectID)) {
return domain.Message{}, effectIDInvalidErr()
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return domain.Message{}, err
}
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
if err != nil {
return domain.Message{}, err
}
if richMessage.IsZero() {
return domain.Message{}, richMessageInvalidErr()
}
var reply *domain.MessageReply
if replyToMessageID > 0 {
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, "", nil, nil, richMessage, replyMarkup, silent, noForwards, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
}
if r.deps.Users != nil && peer.ID != botID {
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
return domain.Message{}, err
} else if !found {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
}
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
SenderUserID: botID, RecipientUserID: peer.ID, RandomID: randomNonZeroInt64(),
RichMessage: richMessage, Silent: silent, NoForwards: noForwards, ReplyTo: reply,
Date: int(time.Now().Unix()), ReplyMarkup: replyMarkup, Effect: effectID,
})
if err != nil {
return domain.Message{}, err
}
return res.SenderMessage, nil
}
// BotAPISendMedia sends a photo/document message through the same files service
// and private/channel message state machines used by MTProto sendMedia.
func (r *Router) 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) {
@ -257,7 +317,7 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
}
if peer.Type == domain.PeerTypeChannel {
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, replyMarkup, silent, reply)
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, nil, replyMarkup, silent, false, reply)
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
@ -569,7 +629,7 @@ func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
return domain.Peer{}, false
}
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, replyMarkup *domain.MessageReplyMarkup, silent bool, reply *domain.MessageReply) (domain.Message, error) {
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, richMessage *domain.MessageRichMessage, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, reply *domain.MessageReply) (domain.Message, error) {
if r.deps.Channels == nil {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
@ -581,10 +641,12 @@ func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID
Message: text,
Entities: append([]domain.MessageEntity(nil), entities...),
Media: media,
RichMessage: richMessage,
MentionUserIDs: mentionUserIDs,
SkipRecipientLookup: true,
PostAuthor: r.channelPostAuthorName(ctx, botID),
Silent: silent,
NoForwards: noForwards,
ReplyTo: reply,
ReplyMarkup: replyMarkup,
Date: int(time.Now().Unix()),
@ -781,6 +843,9 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
EditDate: int(time.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
// An explicit plain-text edit replaces a previous rich payload. Keeping
// both would create a state that neither Bot API nor TDesktop permits.
SetRichMessage: true,
})
if err != nil {
return domain.Message{}, err
@ -792,6 +857,70 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
return self.Message, nil
}
// BotAPIEditRichMessage replaces message content with one rich payload while
// preserving the existing durable edit/pts/outbox semantics.
func (r *Router) BotAPIEditRichMessage(ctx context.Context, botID, chatID int64, messageID int, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
if r == nil || botID == 0 {
return domain.Message{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(chatID)
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return domain.Message{}, err
}
richMessage, err := r.domainRichMessageFromInput(ctx, wire)
if err != nil {
return domain.Message{}, err
}
if richMessage.IsZero() {
return domain.Message{}, richMessageInvalidErr()
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
res, err := r.deps.Channels.EditMessage(ctx, botID, domain.EditChannelMessageRequest{
UserID: botID, ChannelID: peer.ID, ID: messageID, Message: "",
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: true, RichMessage: richMessage, EditDate: int(time.Now().Unix()),
})
if err != nil {
return domain.Message{}, channelEditErr(err)
}
r.enqueueChannelEditMessageFanout(ctx, botID, res)
return botAPIMessageFromChannel(botID, res.Message), nil
}
if r.deps.Messages == nil {
return domain.Message{}, errors.New("BOT_INVALID")
}
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
OwnerUserID: botID, Peer: peer, ID: messageID, Message: "", EditDate: int(time.Now().Unix()),
SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: true, RichMessage: richMessage,
})
if err != nil {
return domain.Message{}, err
}
r.enqueueBotAPIPrivateEditUpdatesAsync(ctx, res)
self := res.Self()
if self.Message.ID == 0 {
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
}
return self.Message, nil
}
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
@ -823,6 +952,29 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, input domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return false, err
}
req := &tg.MessagesEditInlineBotMessageRequest{ID: tgInputBotInlineMessageID(inlineMessageID)}
req.SetRichMessage(wire)
if setReplyMarkup {
markup := tgReplyMarkup(replyMarkup)
if markup == nil {
markup = &tg.ReplyInlineMarkup{}
}
req.SetReplyMarkup(markup)
}
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
// the target user's MTProto clients observe the normal delete update.
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {

View file

@ -249,6 +249,112 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
}
}
func TestBotAPIRichMessagePrivateSendEditAndPlainReplacement(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
}}}}
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, domain.BotAPIRichMessageInput{
HTML: `<h4>Admin</h4><p>Status: active</p>`, SkipEntityDetection: true,
}, markup, false, false, 0, 0)
if err != nil {
t.Fatalf("BotAPISendRichMessage: %v", err)
}
if sent.ID <= 0 || sent.Pts <= 0 || sent.Body != "" || sent.RichMessage == nil || len(sent.RichMessage.BotAPIProjection) == 0 ||
sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "menu:info" {
t.Fatalf("sent rich message = %+v", sent)
}
botHistory := privateBotAPIHistory(t, fixture, fixture.bot.ID, fixture.owner.ID)
if botHistory.ID != sent.ID || botHistory.RichMessage == nil || botHistory.Body != "" {
t.Fatalf("bot rich history = %+v", botHistory)
}
ownerHistory := privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.RichMessage == nil || len(ownerHistory.RichMessage.BotAPIProjection) == 0 || ownerHistory.ReplyMarkup == nil {
t.Fatalf("owner rich history = %+v", ownerHistory)
}
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, domain.BotAPIRichMessageInput{
Markdown: "## Updated\n\nSubscription: active", SkipEntityDetection: true,
}, true, markup)
if err != nil {
t.Fatalf("BotAPIEditRichMessage: %v", err)
}
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated") {
t.Fatalf("edited rich message = %+v projection=%s", edited, edited.RichMessage.BotAPIProjection)
}
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.RichMessage == nil || !strings.Contains(string(ownerHistory.RichMessage.BotAPIProjection), "Updated") {
t.Fatalf("owner edited rich history = %+v", ownerHistory)
}
plain, err := fixture.router.BotAPIEditMessageText(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, "Classic menu", nil, false, nil, false)
if err != nil {
t.Fatalf("BotAPIEditMessageText replacing rich: %v", err)
}
if plain.Body != "Classic menu" || plain.RichMessage != nil {
t.Fatalf("plain replacement = %+v", plain)
}
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
if ownerHistory.Body != "Classic menu" || ownerHistory.RichMessage != nil {
t.Fatalf("owner plain replacement history = %+v", ownerHistory)
}
}
func TestBotAPIRichMessageSupergroupSendAndEdit(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
chatID := -botAPIChannelChatIDBase - fixture.channel.ID
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Status", Data: []byte("channel:status"),
}}}}
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, chatID, domain.BotAPIRichMessageInput{
HTML: `<h4>Group menu</h4><p>Status: active</p>`, SkipEntityDetection: true,
}, markup, false, false, 0, 0)
if err != nil {
t.Fatalf("BotAPISendRichMessage channel: %v", err)
}
if sent.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || sent.ID <= 0 || sent.Pts <= 0 ||
sent.RichMessage == nil || sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "channel:status" {
t.Fatalf("sent channel rich message = %+v", sent)
}
history, err := fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
ChannelID: fixture.channel.ID, Limit: 1,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil || history.Messages[0].Body != "" {
t.Fatalf("channel rich history = %+v err=%v", history.Messages, err)
}
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, chatID, sent.ID, domain.BotAPIRichMessageInput{
Markdown: "## Updated group menu\n\nStatus: active", SkipEntityDetection: true,
}, true, markup)
if err != nil {
t.Fatalf("BotAPIEditRichMessage channel: %v", err)
}
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated group") {
t.Fatalf("edited channel rich message = %+v sent_pts=%d projection=%s", edited, sent.Pts, edited.RichMessage.BotAPIProjection)
}
history, err = fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
ChannelID: fixture.channel.ID, Limit: 1,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil ||
!strings.Contains(string(history.Messages[0].RichMessage.BotAPIProjection), "Updated group") {
t.Fatalf("edited channel history = %+v err=%v", history.Messages, err)
}
}
func privateBotAPIHistory(t *testing.T, fixture botAPIReceiveFixture, ownerID, peerID int64) domain.Message {
t.Helper()
history, err := fixture.messages.GetHistory(fixture.ctx, ownerID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, Limit: 1,
})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("GetHistory owner=%d peer=%d len=%d err=%v", ownerID, peerID, len(history.Messages), err)
}
return history.Messages[0]
}
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)

View file

@ -0,0 +1,305 @@
package rpc
import (
"bytes"
"fmt"
"net/url"
"strconv"
"strings"
richbuilder "github.com/iamxvbaba/td/telegram/message/rich"
"github.com/iamxvbaba/td/tg"
"golang.org/x/net/html"
"telesrv/internal/domain"
)
const (
botAPIRichSentinelScheme = "telesrv-rich"
botAPIRichDateMaxUnix = int64(1<<31 - 1)
)
type botAPIHTMLTableSpec struct {
bordered bool
striped bool
cells []botAPIHTMLTableCellSpec
}
type botAPIHTMLTableCellSpec struct {
align string
valign string
}
func tgInputRichMessageFromBotAPI(input domain.BotAPIRichMessageInput) (tg.InputRichMessageClass, error) {
if input.SourceCount() != 1 || len(input.BlocksJSON) != 0 {
return nil, richMessageInvalidErr()
}
if len(input.MediaJSON) != 0 {
return nil, richMessageMediaUnsupportedErr()
}
if input.HTML != "" {
return &tg.InputRichMessageHTML{
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, HTML: input.HTML,
}, nil
}
if input.Markdown != "" {
return &tg.InputRichMessageMarkdown{
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, Markdown: input.Markdown,
}, nil
}
return nil, richMessageInvalidErr()
}
func parseBotAPIRichHTML(source string) ([]tg.PageBlockClass, error) {
doc, err := html.Parse(strings.NewReader(source))
if err != nil {
return nil, richMessageInvalidErr()
}
tables := make([]botAPIHTMLTableSpec, 0)
var transform func(*html.Node) error
transform = func(node *html.Node) error {
if node.Type == html.ElementNode {
switch node.Data {
case "img", "video", "audio", "tg-map", "tg-collage", "tg-slideshow":
// The current local blob backend cannot materialize an arbitrary
// rich HTML media URL atomically. Fail explicitly so Bedolaga's
// documented one-shot no-logo retry is used instead of losing media.
return webpageMediaEmptyErr()
case "tg-time":
unixTime, err := strconv.ParseInt(htmlNodeAttr(node, "unix"), 10, 64)
if err != nil || unixTime <= 0 || unixTime > botAPIRichDateMaxUnix {
return richMessageDateInvalidErr()
}
format := htmlNodeAttr(node, "format")
if _, ok := botAPIRichDateFlags(format); !ok {
return richMessageDateInvalidErr()
}
node.Data = "a"
node.Attr = []html.Attribute{{Key: "href", Val: fmt.Sprintf("%s://time?unix=%d&format=%s", botAPIRichSentinelScheme, unixTime, url.QueryEscape(format))}}
case "footer":
node.Data = "p"
node.Attr = nil
anchor := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{{Key: "href", Val: botAPIRichSentinelScheme + "://footer"}}}
for child := node.FirstChild; child != nil; {
next := child.NextSibling
node.RemoveChild(child)
anchor.AppendChild(child)
child = next
}
node.AppendChild(anchor)
case "table":
tables = append(tables, botAPIHTMLTableSpecFromNode(node))
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if err := transform(child); err != nil {
return err
}
}
return nil
}
if err := transform(doc); err != nil {
return nil, err
}
var normalized bytes.Buffer
if err := html.Render(&normalized, doc); err != nil {
return nil, richMessageInvalidErr()
}
blocks, err := richbuilder.ParseHTML(strings.NewReader(normalized.String()))
if err != nil {
return nil, richMessageInvalidErr()
}
postProcessBotAPIRichHTML(blocks, tables)
return blocks, nil
}
func parseBotAPIRichMarkdown(source string) ([]tg.PageBlockClass, error) {
blocks, err := richbuilder.ParseMarkdown(strings.NewReader(source))
if err != nil {
return nil, richMessageInvalidErr()
}
return blocks, nil
}
func botAPIHTMLTableSpecFromNode(table *html.Node) botAPIHTMLTableSpec {
spec := botAPIHTMLTableSpec{bordered: htmlNodeHasAttr(table, "bordered"), striped: htmlNodeHasAttr(table, "striped")}
var walk func(*html.Node)
walk = func(node *html.Node) {
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
spec.cells = append(spec.cells, botAPIHTMLTableCellSpec{
align: strings.ToLower(htmlNodeAttr(child, "align")), valign: strings.ToLower(htmlNodeAttr(child, "valign")),
})
}
walk(child)
}
}
walk(table)
return spec
}
func postProcessBotAPIRichHTML(blocks []tg.PageBlockClass, tables []botAPIHTMLTableSpec) {
tableIndex := 0
var visit func([]tg.PageBlockClass)
visit = func(items []tg.PageBlockClass) {
for index, block := range items {
switch value := block.(type) {
case *tg.PageBlockParagraph:
if footer, ok := botAPIRichFooterText(value.Text); ok {
items[index] = &tg.PageBlockFooter{Text: postProcessBotAPIRichText(footer)}
} else {
value.Text = postProcessBotAPIRichText(value.Text)
}
case *tg.PageBlockHeading1:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading2:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading3:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading4:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading5:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockHeading6:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockFooter:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockPreformatted:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.PageBlockBlockquote:
value.Text = postProcessBotAPIRichText(value.Text)
value.Caption = postProcessBotAPIRichText(value.Caption)
case *tg.PageBlockBlockquoteBlocks:
value.Caption = postProcessBotAPIRichText(value.Caption)
visit(value.Blocks)
case *tg.PageBlockDetails:
value.Title = postProcessBotAPIRichText(value.Title)
visit(value.Blocks)
case *tg.PageBlockTable:
value.Title = postProcessBotAPIRichText(value.Title)
if tableIndex < len(tables) {
spec := tables[tableIndex]
tableIndex++
value.Bordered, value.Striped = spec.bordered, spec.striped
cellIndex := 0
for rowIndex := range value.Rows {
for columnIndex := range value.Rows[rowIndex].Cells {
cell := &value.Rows[rowIndex].Cells[columnIndex]
cell.Text = postProcessBotAPIRichText(cell.Text)
if cellIndex < len(spec.cells) {
cellSpec := spec.cells[cellIndex]
cell.AlignCenter = cellSpec.align == "center"
cell.AlignRight = cellSpec.align == "right"
cell.ValignMiddle = cellSpec.valign == "middle"
cell.ValignBottom = cellSpec.valign == "bottom"
}
cellIndex++
}
}
}
}
}
}
visit(blocks)
}
func postProcessBotAPIRichText(text tg.RichTextClass) tg.RichTextClass {
switch value := text.(type) {
case *tg.TextConcat:
for i := range value.Texts {
value.Texts[i] = postProcessBotAPIRichText(value.Texts[i])
}
case *tg.TextBold:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextItalic:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextUnderline:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextStrike:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextFixed:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSubscript:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSuperscript:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextMarked:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextSpoiler:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextURL:
parsed, err := url.Parse(value.URL)
if err == nil && parsed.Scheme == botAPIRichSentinelScheme && parsed.Host == "time" {
unixTime, unixErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
flags, ok := botAPIRichDateFlags(parsed.Query().Get("format"))
if unixErr == nil && ok {
return richbuilder.Date(postProcessBotAPIRichText(value.Text), int(unixTime), flags)
}
}
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextEmail:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextPhone:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextAnchor:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextMentionName:
value.Text = postProcessBotAPIRichText(value.Text)
case *tg.TextDate:
value.Text = postProcessBotAPIRichText(value.Text)
}
return text
}
func botAPIRichFooterText(text tg.RichTextClass) (tg.RichTextClass, bool) {
link, ok := text.(*tg.TextURL)
if !ok || link.URL != botAPIRichSentinelScheme+"://footer" {
return nil, false
}
return link.Text, true
}
func botAPIRichDateFlags(format string) (richbuilder.DateFlags, bool) {
if format == "r" || format == "R" {
return richbuilder.DateFlags{Relative: true}, true
}
var flags richbuilder.DateFlags
if format == "" {
return flags, false
}
for _, value := range format {
switch value {
case 't':
flags.ShortTime = true
case 'T':
flags.LongTime = true
case 'd':
flags.ShortDate = true
case 'D':
flags.LongDate = true
case 'w', 'W':
flags.DayOfWeek = true
default:
return richbuilder.DateFlags{}, false
}
}
return flags, true
}
func htmlNodeAttr(node *html.Node, key string) string {
for _, attribute := range node.Attr {
if attribute.Key == key {
return attribute.Val
}
}
return ""
}
func htmlNodeHasAttr(node *html.Node, key string) bool {
for _, attribute := range node.Attr {
if attribute.Key == key {
return true
}
}
return false
}

View file

@ -0,0 +1,503 @@
package rpc
import (
"encoding/json"
"errors"
"strconv"
"strings"
"github.com/iamxvbaba/td/tg"
)
func botAPIRichMessageProjection(blocks []tg.PageBlockClass, rtl bool) ([]byte, error) {
projected, err := botAPIRichBlocks(blocks)
if err != nil {
return nil, err
}
if len(projected) == 0 {
return nil, richMessageInvalidErr()
}
out := map[string]any{"blocks": projected}
if rtl {
out["is_rtl"] = true
}
return json.Marshal(out)
}
func botAPIRichBlocks(blocks []tg.PageBlockClass) ([]any, error) {
out := make([]any, 0, len(blocks))
for _, block := range blocks {
projected, err := botAPIRichBlock(block)
if err != nil {
return nil, err
}
if projected != nil {
out = append(out, projected)
}
}
return out, nil
}
func botAPIRichBlock(block tg.PageBlockClass) (map[string]any, error) {
textBlock := func(kind string, text tg.RichTextClass) (map[string]any, error) {
value, err := botAPIRichText(text)
if err != nil {
return nil, err
}
return map[string]any{"type": kind, "text": value}, nil
}
heading := func(size int, text tg.RichTextClass) (map[string]any, error) {
value, err := botAPIRichText(text)
if err != nil {
return nil, err
}
return map[string]any{"type": "heading", "text": value, "size": size}, nil
}
switch value := block.(type) {
case *tg.PageBlockParagraph:
return textBlock("paragraph", value.Text)
case *tg.PageBlockTitle:
return heading(1, value.Text)
case *tg.PageBlockSubtitle:
return heading(2, value.Text)
case *tg.PageBlockHeader:
return heading(2, value.Text)
case *tg.PageBlockSubheader:
return heading(3, value.Text)
case *tg.PageBlockKicker:
return heading(6, value.Text)
case *tg.PageBlockHeading1:
return heading(1, value.Text)
case *tg.PageBlockHeading2:
return heading(2, value.Text)
case *tg.PageBlockHeading3:
return heading(3, value.Text)
case *tg.PageBlockHeading4:
return heading(4, value.Text)
case *tg.PageBlockHeading5:
return heading(5, value.Text)
case *tg.PageBlockHeading6:
return heading(6, value.Text)
case *tg.PageBlockPreformatted:
out, err := textBlock("pre", value.Text)
if err == nil && value.Language != "" {
out["language"] = value.Language
}
return out, err
case *tg.PageBlockFooter:
return textBlock("footer", value.Text)
case *tg.PageBlockDivider:
return map[string]any{"type": "divider"}, nil
case *tg.PageBlockMath:
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
case *tg.PageBlockAnchor:
return map[string]any{"type": "anchor", "name": value.Name}, nil
case *tg.PageBlockDetails:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
summary, err := botAPIRichText(value.Title)
if err != nil {
return nil, err
}
out := map[string]any{"type": "details", "summary": summary, "blocks": blocks}
if value.Open {
out["is_open"] = true
}
return out, nil
case *tg.PageBlockBlockquote:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
out := map[string]any{"type": "blockquote", "blocks": []any{map[string]any{"type": "paragraph", "text": text}}}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockBlockquoteBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
out := map[string]any{"type": "blockquote", "blocks": blocks}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockPullquote:
out, err := textBlock("pullquote", value.Text)
if err != nil {
return nil, err
}
if !botAPIRichTextEmpty(value.Caption) {
credit, err := botAPIRichText(value.Caption)
if err != nil {
return nil, err
}
out["credit"] = credit
}
return out, nil
case *tg.PageBlockList:
return botAPIUnorderedRichList(value)
case *tg.PageBlockOrderedList:
return botAPIOrderedRichList(value)
case *tg.PageBlockTable:
return botAPIRichTable(value)
case *tg.PageBlockCollage:
return botAPIRichBlockCollection("collage", value.Items, value.Caption)
case *tg.PageBlockSlideshow:
return botAPIRichBlockCollection("slideshow", value.Items, value.Caption)
case *tg.PageBlockCover:
return botAPIRichBlock(value.Cover)
case *tg.PageBlockThinking:
return textBlock("thinking", value.Text)
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
}
func botAPIUnorderedRichList(list *tg.PageBlockList) (map[string]any, error) {
items := make([]any, 0, len(list.Items))
for _, raw := range list.Items {
item := map[string]any{"label": "•"}
switch value := raw.(type) {
case *tg.PageListItemText:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
if value.Checkbox {
item["has_checkbox"] = true
if value.Checked {
item["is_checked"] = true
}
}
case *tg.PageListItemBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
item["blocks"] = blocks
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
items = append(items, item)
}
return map[string]any{"type": "list", "items": items}, nil
}
func botAPIOrderedRichList(list *tg.PageBlockOrderedList) (map[string]any, error) {
items := make([]any, 0, len(list.Items))
for index, raw := range list.Items {
item := map[string]any{"label": strconv.Itoa(index + 1)}
switch value := raw.(type) {
case *tg.PageListOrderedItemText:
text, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
case *tg.PageListOrderedItemBlocks:
blocks, err := botAPIRichBlocks(value.Blocks)
if err != nil {
return nil, err
}
item["blocks"] = blocks
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
items = append(items, item)
}
return map[string]any{"type": "list", "items": items}, nil
}
func botAPIFillOrderedListItem(item map[string]any, label string, value int, kind string, checkbox, checked bool) {
if label != "" {
item["label"] = label
}
if value != 0 {
item["value"] = value
}
if kind != "" {
item["type"] = kind
}
if checkbox {
item["has_checkbox"] = true
if checked {
item["is_checked"] = true
}
}
}
func botAPIRichTable(table *tg.PageBlockTable) (map[string]any, error) {
rows := make([]any, 0, len(table.Rows))
for _, row := range table.Rows {
cells := make([]any, 0, len(row.Cells))
for _, cell := range row.Cells {
item := map[string]any{"align": "left", "valign": "top"}
if !botAPIRichTextEmpty(cell.Text) {
text, err := botAPIRichText(cell.Text)
if err != nil {
return nil, err
}
item["text"] = text
}
if cell.Header {
item["is_header"] = true
}
if cell.Colspan > 1 {
item["colspan"] = cell.Colspan
}
if cell.Rowspan > 1 {
item["rowspan"] = cell.Rowspan
}
if cell.AlignCenter {
item["align"] = "center"
} else if cell.AlignRight {
item["align"] = "right"
}
if cell.ValignMiddle {
item["valign"] = "middle"
} else if cell.ValignBottom {
item["valign"] = "bottom"
}
cells = append(cells, item)
}
rows = append(rows, cells)
}
out := map[string]any{"type": "table", "cells": rows}
if table.Bordered {
out["is_bordered"] = true
}
if table.Striped {
out["is_striped"] = true
}
if !botAPIRichTextEmpty(table.Title) {
caption, err := botAPIRichText(table.Title)
if err != nil {
return nil, err
}
out["caption"] = caption
}
return out, nil
}
func botAPIRichBlockCollection(kind string, blocks []tg.PageBlockClass, caption tg.PageCaption) (map[string]any, error) {
items, err := botAPIRichBlocks(blocks)
if err != nil {
return nil, err
}
out := map[string]any{"type": kind, "blocks": items}
if !botAPIRichTextEmpty(caption.Text) || !botAPIRichTextEmpty(caption.Credit) {
projected := map[string]any{}
if !botAPIRichTextEmpty(caption.Text) {
projected["text"], err = botAPIRichText(caption.Text)
}
if err == nil && !botAPIRichTextEmpty(caption.Credit) {
projected["credit"], err = botAPIRichText(caption.Credit)
}
if err != nil {
return nil, err
}
out["caption"] = projected
}
return out, nil
}
func botAPIRichText(text tg.RichTextClass) (any, error) {
wrapped := func(kind string, child tg.RichTextClass) (any, error) {
value, err := botAPIRichText(child)
if err != nil {
return nil, err
}
return map[string]any{"type": kind, "text": value}, nil
}
valued := func(kind, field, value string, child tg.RichTextClass) (any, error) {
out, err := wrapped(kind, child)
if err != nil {
return nil, err
}
out.(map[string]any)[field] = value
return out, nil
}
switch value := text.(type) {
case nil, *tg.TextEmpty:
return "", nil
case *tg.TextPlain:
return value.Text, nil
case *tg.TextConcat:
items := make([]any, 0, len(value.Texts))
for _, child := range value.Texts {
item, err := botAPIRichText(child)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
case *tg.TextBold:
return wrapped("bold", value.Text)
case *tg.TextItalic:
return wrapped("italic", value.Text)
case *tg.TextUnderline:
return wrapped("underline", value.Text)
case *tg.TextStrike:
return wrapped("strikethrough", value.Text)
case *tg.TextSpoiler:
return wrapped("spoiler", value.Text)
case *tg.TextFixed:
return wrapped("code", value.Text)
case *tg.TextSubscript:
return wrapped("subscript", value.Text)
case *tg.TextSuperscript:
return wrapped("superscript", value.Text)
case *tg.TextMarked:
return wrapped("marked", value.Text)
case *tg.TextDate:
out, err := wrapped("date_time", value.Text)
if err != nil {
return nil, err
}
item := out.(map[string]any)
item["unix_time"] = value.Date
item["date_time_format"] = botAPIRichDateFormat(value)
return item, nil
case *tg.TextCustomEmoji:
return map[string]any{"type": "custom_emoji", "custom_emoji_id": strconv.FormatInt(value.DocumentID, 10), "alternative_text": value.Alt}, nil
case *tg.TextMath:
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
case *tg.TextURL:
if strings.HasPrefix(value.URL, "#") {
return valued("anchor_link", "anchor_name", strings.TrimPrefix(value.URL, "#"), value.Text)
}
return valued("url", "url", value.URL, value.Text)
case *tg.TextEmail:
return valued("email_address", "email_address", value.Email, value.Text)
case *tg.TextPhone:
return valued("phone_number", "phone_number", value.Phone, value.Text)
case *tg.TextBankCard:
return valued("bank_card_number", "bank_card_number", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextMention:
return valued("mention", "username", strings.TrimPrefix(botAPIRichPlainText(value.Text), "@"), value.Text)
case *tg.TextHashtag:
return valued("hashtag", "hashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "#"), value.Text)
case *tg.TextCashtag:
return valued("cashtag", "cashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "$"), value.Text)
case *tg.TextBotCommand:
return valued("bot_command", "bot_command", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoURL:
return valued("url", "url", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoEmail:
return valued("email_address", "email_address", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextAutoPhone:
return valued("phone_number", "phone_number", botAPIRichPlainText(value.Text), value.Text)
case *tg.TextMentionName:
out, err := wrapped("text_mention", value.Text)
if err != nil {
return nil, err
}
out.(map[string]any)["user"] = map[string]any{"id": value.UserID, "is_bot": false, "first_name": "User " + strconv.FormatInt(value.UserID, 10)}
return out, nil
case *tg.TextAnchor:
anchor := map[string]any{"type": "anchor", "name": value.Name}
if botAPIRichTextEmpty(value.Text) {
return anchor, nil
}
inner, err := botAPIRichText(value.Text)
if err != nil {
return nil, err
}
return []any{anchor, inner}, nil
default:
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
}
}
func botAPIRichDateFormat(date *tg.TextDate) string {
if date.Relative {
return "r"
}
var out strings.Builder
if date.ShortTime {
out.WriteByte('t')
}
if date.LongTime {
out.WriteByte('T')
}
if date.ShortDate {
out.WriteByte('d')
}
if date.LongDate {
out.WriteByte('D')
}
if date.DayOfWeek {
out.WriteByte('w')
}
return out.String()
}
func botAPIRichTextEmpty(text tg.RichTextClass) bool {
return text == nil || botAPIRichPlainText(text) == ""
}
func botAPIRichPlainText(text tg.RichTextClass) string {
var out strings.Builder
var walk func(tg.RichTextClass)
walk = func(value tg.RichTextClass) {
switch value := value.(type) {
case *tg.TextPlain:
out.WriteString(value.Text)
case *tg.TextConcat:
for _, child := range value.Texts {
walk(child)
}
case *tg.TextBold:
walk(value.Text)
case *tg.TextItalic:
walk(value.Text)
case *tg.TextUnderline:
walk(value.Text)
case *tg.TextStrike:
walk(value.Text)
case *tg.TextFixed:
walk(value.Text)
case *tg.TextSubscript:
walk(value.Text)
case *tg.TextSuperscript:
walk(value.Text)
case *tg.TextMarked:
walk(value.Text)
case *tg.TextSpoiler:
walk(value.Text)
case *tg.TextURL:
walk(value.Text)
case *tg.TextEmail:
walk(value.Text)
case *tg.TextPhone:
walk(value.Text)
case *tg.TextAnchor:
walk(value.Text)
case *tg.TextMentionName:
walk(value.Text)
case *tg.TextDate:
walk(value.Text)
case *tg.TextCustomEmoji:
out.WriteString(value.Alt)
}
}
walk(text)
return out.String()
}

View file

@ -10,10 +10,9 @@ import (
"telesrv/internal/domain"
)
// 本文件集中 Layer 227 富文本消息richMessage的 tg.* ↔ domain 转换。
// Phase 1仅支持 inputRichMessageblocks 形态HTML/Markdown 变体(需服务端解析为
// PageBlock尚未实现直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain详见
// domain.MessageRichMessage
// 本文件集中 Layer 228 富文本消息richMessage的 tg.* ↔ domain 转换。
// inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock
// blocks 以 TL 向量序列化为不透明字节存 domain详见 domain.MessageRichMessage
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
@ -127,22 +126,51 @@ func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
}
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
// 序列化 blocks + 按 id 解析内嵌 photos/documents复用 sendMedia 同款媒体解析)。
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
// HTML/Markdown 先在服务端解析为 PageBlock再与 blocks 形态共用限额校验、
// 序列化和 Bot API 输出投影;内嵌 photos/documents 复用 sendMedia 同款媒体解析。
// 返回 nil 表示无富文本载荷。
func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) {
if input == nil {
return nil, nil
}
in, ok := input.(*tg.InputRichMessage)
if !ok {
// Phase 1HTML/Markdown 变体需服务端解析为 PageBlock尚未支持。
return nil, mediaInvalidErr()
var (
in *tg.InputRichMessage
sourceParsed bool
)
switch value := input.(type) {
case *tg.InputRichMessage:
in = value
case *tg.InputRichMessageHTML:
if value == nil || value.HTML == "" || len(value.Files) != 0 {
return nil, richMessageInvalidErr()
}
blocks, err := parseBotAPIRichHTML(value.HTML)
if err != nil {
return nil, err
}
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
sourceParsed = true
case *tg.InputRichMessageMarkdown:
if value == nil || value.Markdown == "" || len(value.Files) != 0 {
return nil, richMessageInvalidErr()
}
blocks, err := parseBotAPIRichMarkdown(value.Markdown)
if err != nil {
return nil, err
}
in = &tg.InputRichMessage{Rtl: value.Rtl, Noautolink: value.Noautolink, Blocks: blocks}
sourceParsed = true
default:
return nil, richMessageInvalidErr()
}
if len(in.Blocks) == 0 {
if len(in.Photos) == 0 && len(in.Documents) == 0 {
return nil, nil
}
return nil, mediaInvalidErr()
return nil, richMessageInvalidErr()
}
if err := validateRichMessageBlocks(in.Blocks); err != nil {
return nil, err
}
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
return nil, notImplementedErr()
@ -156,6 +184,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
Rtl: in.Rtl,
Blocks: blocks,
}
projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl)
if projectionErr != nil && sourceParsed {
return nil, richMessageInvalidErr()
}
if projectionErr == nil {
rich.BotAPIProjection = projection
}
for _, p := range in.Photos {
id, ok := inputPhotoID(p)
if !ok {

View file

@ -79,6 +79,18 @@ func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") }
func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") }
func richMessageInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_INVALID") }
func richMessageTooLongErr() error { return tgerr.New(400, "RICH_MESSAGE_TOO_LONG") }
func richMessageDateInvalidErr() error { return tgerr.New(400, "RICH_MESSAGE_DATE_INVALID") }
func richMessageMediaUnsupportedErr() error {
return tgerr.New(400, "RICH_MESSAGE_MEDIA_UNSUPPORTED")
}
func webpageMediaEmptyErr() error { return tgerr.New(400, "WEBPAGE_MEDIA_EMPTY") }
func mediaTypeInvalidErr() error { return tgerr.New(400, "MEDIA_TYPE_INVALID") }
func urlInvalidErr() error { return tgerr.New(400, "URL_INVALID") }

View file

@ -114,7 +114,23 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
}
message := target.Body
entities := append([]domain.MessageEntity(nil), target.Entities...)
if rawMessage, ok := req.GetMessage(); ok {
richMessage := target.RichMessage
setRichMessage := false
rawRichMessage, hasRichMessage := req.GetRichMessage()
rawMessage, hasMessage := req.GetMessage()
if hasMessage && hasRichMessage {
return false, mediaInvalidErr()
}
if hasRichMessage {
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
if err != nil {
return false, err
}
if richMessage.IsZero() {
return false, richMessageInvalidErr()
}
message, entities, setRichMessage = "", nil, true
} else if hasMessage {
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
return false, messageEmptyErr()
}
@ -127,6 +143,7 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
}
message = rawMessage
entities = domainMessageEntitiesForViewer(botID, rawEntities)
richMessage, setRichMessage = nil, true
} else if req.ReplyMarkup == nil && newMedia == nil {
return false, messageNotModifiedErr()
}
@ -152,6 +169,8 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
EditDate: int(r.clock.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: setRichMessage,
RichMessage: richMessage,
ViaBotEditBotID: botID,
})
if err != nil {
@ -171,7 +190,23 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
message := target.Body
entities := append([]domain.MessageEntity(nil), target.Entities...)
var mentionUserIDs []int64
if rawMessage, ok := req.GetMessage(); ok {
richMessage := target.RichMessage
setRichMessage := false
rawRichMessage, hasRichMessage := req.GetRichMessage()
rawMessage, hasMessage := req.GetMessage()
if hasMessage && hasRichMessage {
return false, mediaInvalidErr()
}
if hasRichMessage {
richMessage, err = r.domainRichMessageFromInput(ctx, rawRichMessage)
if err != nil {
return false, err
}
if richMessage.IsZero() {
return false, richMessageInvalidErr()
}
message, entities, setRichMessage = "", nil, true
} else if hasMessage {
if rawMessage == "" && newMedia == nil && target.Media.IsZero() {
return false, messageEmptyErr()
}
@ -184,6 +219,7 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
}
message = rawMessage
entities = domainMessageEntitiesForViewer(botID, rawEntities)
richMessage, setRichMessage = nil, true
var err error
mentionUserIDs, err = r.mentionedUserIDsFromMessage(ctx, botID, message, rawEntities)
if err != nil {
@ -221,6 +257,8 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
EditDate: int(r.clock.Now().Unix()),
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: setRichMessage,
RichMessage: richMessage,
ViaBotEditBotID: botID,
})
if err != nil {

View file

@ -41,6 +41,12 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
return nil, richErr
}
}
if hasMessage && hasRichMessage {
return nil, mediaInvalidErr()
}
// Explicit text and rich edits are replacement operations. A text edit must
// clear a previously stored rich payload; a rich edit already replaces it.
replaceRichMessage := hasRichMessage || hasMessage
if hasMessage && richMessage == nil {
// 编辑后的文本同样补服务端自动实体url/@mention/#hashtag/bot command与发送一致
// 覆盖频道/私聊编辑与各自的定时编辑分支editScheduledMessage 仅由本处调用)。
@ -61,7 +67,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
return nil, mediaInvalidErr()
}
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, replaceRichMessage, scheduleDate)
}
if media, ok := req.GetMedia(); ok {
// 关闭 poll 走 editMessage + InputMediaPoll(closed)TDesktop "Stop poll" 路径)。
@ -119,7 +125,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities),
MentionUserIDs: mentionUserIDs,
SetRichMessage: hasRichMessage,
SetRichMessage: replaceRichMessage,
RichMessage: richMessage,
EditDate: int(r.clock.Now().Unix()),
})
@ -154,7 +160,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
OriginSessionID: sessionID,
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: hasRichMessage,
SetRichMessage: replaceRichMessage,
RichMessage: richMessage,
})
if err != nil {

View file

@ -2,10 +2,13 @@ package rpc
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
@ -248,6 +251,108 @@ func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
}
func TestBotAPIRichHTMLParsesBedolagaMenuStructures(t *testing.T) {
r := &Router{}
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
Rtl: true,
Noautolink: true,
HTML: `<h4>Admin</h4>
<table bordered striped><tr><th>Status</th><td align="right" valign="bottom"><tg-time unix="1700000000" format="R">now</tg-time></td></tr></table>
<details open><summary>More</summary><p><blockquote><code>healthy</code></blockquote></p></details>
<footer>Choose an option</footer>`,
})
if err != nil {
t.Fatalf("parse Bedolaga rich HTML: %v", err)
}
decoded, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("decode rich HTML: %v", err)
}
if !decoded.Rtl {
t.Fatal("rich HTML lost is_rtl")
}
var heading, table, details, footer bool
for _, block := range decoded.Blocks {
switch value := block.(type) {
case *tg.PageBlockHeading4:
heading = true
case *tg.PageBlockTable:
table = true
if !value.Bordered || !value.Striped || len(value.Rows) != 1 || len(value.Rows[0].Cells) != 2 {
t.Fatalf("table shape = %+v", value)
}
cell := value.Rows[0].Cells[1]
if !cell.AlignRight || !cell.ValignBottom || !richTextContainsDate(cell.Text, 1700000000) {
t.Fatalf("table date/alignment = %+v", cell)
}
case *tg.PageBlockDetails:
details = value.Open && len(value.Blocks) != 0
case *tg.PageBlockFooter:
footer = true
}
}
if !heading || !table || !details || !footer {
t.Fatalf("parsed blocks heading=%v table=%v details=%v footer=%v: %#v", heading, table, details, footer, decoded.Blocks)
}
var projected struct {
RTL bool `json:"is_rtl"`
Blocks []struct {
Type string `json:"type"`
} `json:"blocks"`
}
if err := json.Unmarshal(rich.BotAPIProjection, &projected); err != nil {
t.Fatalf("decode Bot API projection: %v", err)
}
if !projected.RTL || len(projected.Blocks) != len(decoded.Blocks) {
t.Fatalf("Bot API projection = %s", rich.BotAPIProjection)
}
if _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
HTML: `<h4>Admin</h4><img src="https://example.test/logo.png">`,
}); err == nil || !tgerr.Is(err, "WEBPAGE_MEDIA_EMPTY") {
t.Fatalf("HTML media err = %v, want WEBPAGE_MEDIA_EMPTY for Bedolaga no-logo retry", err)
}
}
func TestBotAPIRichMarkdownParsesAndProjects(t *testing.T) {
r := &Router{}
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageMarkdown{
Markdown: "# Bedolaga\n\n**Subscription:** active",
})
if err != nil {
t.Fatalf("parse rich Markdown: %v", err)
}
decoded, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("decode rich Markdown: %v", err)
}
if len(decoded.Blocks) < 2 || len(rich.BotAPIProjection) == 0 || !strings.Contains(string(rich.BotAPIProjection), "Bedolaga") {
t.Fatalf("Markdown decoded=%#v projection=%s", decoded.Blocks, rich.BotAPIProjection)
}
}
func richTextContainsDate(text tg.RichTextClass, want int) bool {
switch value := text.(type) {
case *tg.TextDate:
return value.Date == want
case *tg.TextConcat:
for _, child := range value.Texts {
if richTextContainsDate(child, want) {
return true
}
}
case *tg.TextBold:
return richTextContainsDate(value.Text, want)
case *tg.TextItalic:
return richTextContainsDate(value.Text, want)
case *tg.TextFixed:
return richTextContainsDate(value.Text, want)
case *tg.TextURL:
return richTextContainsDate(value.Text, want)
}
return false
}
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
ctx := context.Background()
r := &Router{}
@ -417,7 +522,7 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich",
Message: "",
RandomID: 7001,
RichMessage: &tg.InputRichMessage{
Rtl: true,
@ -642,7 +747,7 @@ func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich",
Message: "",
RandomID: 7002,
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
})
@ -681,7 +786,7 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "rich+photo",
Message: "",
RandomID: 7003,
RichMessage: &tg.InputRichMessage{
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}},

View file

@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr
}
}
// rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误
// rich_messageLayer 228 富文本blocks、HTML、Markdown 均在边界归一为
// PageBlock + 内嵌媒体快照;普通消息恒 nil
var richMessage *domain.MessageRichMessage
if req.RichMessage != nil {
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
@ -194,6 +194,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr
}
}
if req.Message != "" && richMessage != nil {
sendErr = mediaInvalidErr()
return nil, sendErr
}
if req.Message == "" && richMessage == nil {
sendErr = messageEmptyErr()
return nil, sendErr

View file

@ -0,0 +1,215 @@
package rpc
import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
)
const (
richMessageLengthLimit = 32768
richMessageMaxBlocks = 500
richMessageMaxDepth = 16
richMessageMaxMedia = 50
richMessageMaxTableCols = 20
)
type richMessageMetrics struct {
textLength int
blocks int
depth int
media int
tableCols int
}
func validateRichMessageBlocks(blocks []tg.PageBlockClass) error {
metrics := richMessageMetrics{}
collectRichMessageBlockMetrics(blocks, 1, &metrics)
if metrics.textLength > richMessageLengthLimit || metrics.blocks > richMessageMaxBlocks ||
metrics.depth > richMessageMaxDepth || metrics.media > richMessageMaxMedia || metrics.tableCols > richMessageMaxTableCols {
return richMessageTooLongErr()
}
if metrics.blocks == 0 || metrics.textLength == 0 && metrics.media == 0 {
return richMessageInvalidErr()
}
return nil
}
func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metrics *richMessageMetrics) {
if depth > metrics.depth {
metrics.depth = depth
}
for _, block := range blocks {
metrics.blocks++
switch value := block.(type) {
case *tg.PageBlockTitle:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockSubtitle:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeader:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockSubheader:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockKicker:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockParagraph:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockPreformatted:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockFooter:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading1:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading2:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading3:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading4:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading5:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockHeading6:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockMath:
metrics.textLength += utf16StringLength(value.Source)
case *tg.PageBlockThinking:
metrics.textLength += richTextUTF16Length(value.Text)
case *tg.PageBlockAuthorDate:
metrics.textLength += richTextUTF16Length(value.Author)
case *tg.PageBlockBlockquote:
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
case *tg.PageBlockPullquote:
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
case *tg.PageBlockBlockquoteBlocks:
metrics.textLength += richTextUTF16Length(value.Caption)
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockDetails:
metrics.textLength += richTextUTF16Length(value.Title)
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockList:
for _, item := range value.Items {
switch item := item.(type) {
case *tg.PageListItemText:
metrics.textLength += richTextUTF16Length(item.Text)
case *tg.PageListItemBlocks:
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
}
}
case *tg.PageBlockOrderedList:
for _, item := range value.Items {
switch item := item.(type) {
case *tg.PageListOrderedItemText:
metrics.textLength += richTextUTF16Length(item.Text)
case *tg.PageListOrderedItemBlocks:
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
}
}
case *tg.PageBlockTable:
metrics.textLength += richTextUTF16Length(value.Title)
for _, row := range value.Rows {
columns := 0
for _, cell := range row.Cells {
metrics.textLength += richTextUTF16Length(cell.Text)
if cell.Colspan > 1 {
columns += cell.Colspan
} else {
columns++
}
}
if columns > metrics.tableCols {
metrics.tableCols = columns
}
}
case *tg.PageBlockCollage:
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
case *tg.PageBlockSlideshow:
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
case *tg.PageBlockCover:
collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics)
case *tg.PageBlockEmbedPost:
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio:
metrics.media++
}
}
}
func richTextUTF16Length(text tg.RichTextClass) int {
switch value := text.(type) {
case nil, *tg.TextEmpty:
return 0
case *tg.TextPlain:
return utf16StringLength(value.Text)
case *tg.TextConcat:
total := 0
for _, child := range value.Texts {
total += richTextUTF16Length(child)
}
return total
case *tg.TextBold:
return richTextUTF16Length(value.Text)
case *tg.TextItalic:
return richTextUTF16Length(value.Text)
case *tg.TextUnderline:
return richTextUTF16Length(value.Text)
case *tg.TextStrike:
return richTextUTF16Length(value.Text)
case *tg.TextFixed:
return richTextUTF16Length(value.Text)
case *tg.TextSubscript:
return richTextUTF16Length(value.Text)
case *tg.TextSuperscript:
return richTextUTF16Length(value.Text)
case *tg.TextMarked:
return richTextUTF16Length(value.Text)
case *tg.TextSpoiler:
return richTextUTF16Length(value.Text)
case *tg.TextURL:
return richTextUTF16Length(value.Text)
case *tg.TextMention:
return richTextUTF16Length(value.Text)
case *tg.TextHashtag:
return richTextUTF16Length(value.Text)
case *tg.TextBotCommand:
return richTextUTF16Length(value.Text)
case *tg.TextCashtag:
return richTextUTF16Length(value.Text)
case *tg.TextAutoURL:
return richTextUTF16Length(value.Text)
case *tg.TextAutoEmail:
return richTextUTF16Length(value.Text)
case *tg.TextAutoPhone:
return richTextUTF16Length(value.Text)
case *tg.TextBankCard:
return richTextUTF16Length(value.Text)
case *tg.TextEmail:
return richTextUTF16Length(value.Text)
case *tg.TextPhone:
return richTextUTF16Length(value.Text)
case *tg.TextAnchor:
return richTextUTF16Length(value.Text)
case *tg.TextMentionName:
return richTextUTF16Length(value.Text)
case *tg.TextDate:
return richTextUTF16Length(value.Text)
case *tg.TextCustomEmoji:
return utf16StringLength(value.Alt)
case *tg.TextMath:
return utf16StringLength(value.Source)
default:
return 0
}
}
func utf16StringLength(value string) int {
length := 0
for _, r := range value {
length++
if r > utf8.RuneSelf && r > 0xffff {
length++
}
}
return length
}

View file

@ -156,7 +156,7 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
return &clone
}
// cloneRichMessage 深拷 Layer 227 富文本快照:复制不透明 blocks 字节与内嵌媒体切片,
// cloneRichMessage 深拷 Layer 228 富文本快照:复制不透明 blocks、Bot API 投影与内嵌媒体切片,
// 避免发送方/接收方两行共享底层切片(与 postgres 每盒独立 decode 对齐)。
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
if m == nil {
@ -166,6 +166,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
clone.Blocks = append([]byte(nil), m.Blocks...)
clone.Photos = append([]domain.Photo(nil), m.Photos...)
clone.Documents = append([]domain.Document(nil), m.Documents...)
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
return &clone
}