From 1a2d03f529c5bfaff214a8ec3b5e121aa1d61eda Mon Sep 17 00:00:00 2001 From: A Date: Tue, 21 Jul 2026 15:44:43 +0800 Subject: [PATCH] feat: sync bot rich messages and inline menus --- cmd/bots/bedolagaformat/README.md | 19 + cmd/bots/bedolagaformat/demo.py | 127 ++++- cmd/bots/bedolagaformat/test_demo.py | 42 ++ internal/app/dialogs/read_model_cache.go | 2 + internal/app/dialogs/service.go | 1 + internal/botapi/projection.go | 8 +- internal/botapi/rich_message.go | 99 ++++ internal/botapi/server.go | 113 +++- internal/botapi/server_test.go | 124 +++++ internal/domain/botapi_rich_message.go | 29 + internal/domain/channel.go | 2 +- internal/domain/message.go | 13 +- internal/rpc/botapi_gateway.go | 158 +++++- internal/rpc/botapi_gateway_test.go | 106 ++++ internal/rpc/botapi_rich_message.go | 305 +++++++++++ internal/rpc/botapi_rich_projection.go | 503 ++++++++++++++++++ internal/rpc/convert_rich_message.go | 57 +- internal/rpc/errors.go | 12 + internal/rpc/messages_bot_no_state.go | 42 +- internal/rpc/messages_edit.go | 12 +- .../rpc/messages_rich_message_rpc_test.go | 111 +++- internal/rpc/messages_send.go | 8 +- internal/rpc/rich_message_limits.go | 215 ++++++++ internal/store/memory/message_helpers.go | 3 +- 24 files changed, 2073 insertions(+), 38 deletions(-) create mode 100644 internal/botapi/rich_message.go create mode 100644 internal/domain/botapi_rich_message.go create mode 100644 internal/rpc/botapi_rich_message.go create mode 100644 internal/rpc/botapi_rich_projection.go create mode 100644 internal/rpc/rich_message_limits.go diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md index 32dfccb4..155cff04 100644 --- a/cmd/bots/bedolagaformat/README.md +++ b/cmd/bots/bedolagaformat/README.md @@ -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 menu(HTML + 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 写入文件。 diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py index a90746e5..d339a8db 100644 --- a/cmd/bots/bedolagaformat/demo.py +++ b/cmd/bots/bedolagaformat/demo.py @@ -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 = '' if include_logo else "" + return ( + f"{logo}

{marker} Admin

" + "
Subscription overview

" + "" + "" + "
StatusActive
Updated" + 'now' + "
" + "
Diagnostics" + "
rich menu online
" + "" + ) + + +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: - await send_format_suite(bot, args.send_chat_id, args.marker) + 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"], diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py index 26530af2..d4e005e2 100644 --- a/cmd/bots/bedolagaformat/test_demo.py +++ b/cmd/bots/bedolagaformat/test_demo.py @@ -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("

BEDOLAGA123 Admin

", html) + self.assertIn("", html) + self.assertIn("", html) + self.assertIn("
", 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="

fixture

"), + ), + 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(" 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 } diff --git a/internal/botapi/rich_message.go b/internal/botapi/rich_message.go new file mode 100644 index 00000000..2fe9d309 --- /dev/null +++ b/internal/botapi/rich_message.go @@ -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 +} diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 5495724c..333fe288 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -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", diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index b6f33227..e6afa8de 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -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":"

Admin

","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 != "

Admin

" || + !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":"

rich

"} + }`) + 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 diff --git a/internal/domain/botapi_rich_message.go b/internal/domain/botapi_rich_message.go new file mode 100644 index 00000000..514cb624 --- /dev/null +++ b/internal/domain/botapi_rich_message.go @@ -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 +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 5bc861da..027956a6 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -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 diff --git a/internal/domain/message.go b/internal/domain/message.go index 679bd7d3..c46fc9e0 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -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 // PageBlock(Blocks)+ 内嵌已解析的 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 仅支持 inputRichMessage(blocks 形态),不解析 -// 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 } diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index faf232d2..2df966ed 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -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) { diff --git a/internal/rpc/botapi_gateway_test.go b/internal/rpc/botapi_gateway_test.go index 902ec78b..1b6b8363 100644 --- a/internal/rpc/botapi_gateway_test.go +++ b/internal/rpc/botapi_gateway_test.go @@ -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: `

Admin

Status: active

`, 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: `

Group menu

Status: active

`, 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) diff --git a/internal/rpc/botapi_rich_message.go b/internal/rpc/botapi_rich_message.go new file mode 100644 index 00000000..22d9bc3d --- /dev/null +++ b/internal/rpc/botapi_rich_message.go @@ -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 +} diff --git a/internal/rpc/botapi_rich_projection.go b/internal/rpc/botapi_rich_projection.go new file mode 100644 index 00000000..6d9f8e95 --- /dev/null +++ b/internal/rpc/botapi_rich_projection.go @@ -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() +} diff --git a/internal/rpc/convert_rich_message.go b/internal/rpc/convert_rich_message.go index 5397e13b..5bb39a82 100644 --- a/internal/rpc/convert_rich_message.go +++ b/internal/rpc/convert_rich_message.go @@ -10,10 +10,9 @@ import ( "telesrv/internal/domain" ) -// 本文件集中 Layer 227 富文本消息(richMessage)的 tg.* ↔ domain 转换。 -// Phase 1:仅支持 inputRichMessage(blocks 形态);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 1:HTML/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 { diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index d429bf1f..9a969948 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/messages_bot_no_state.go b/internal/rpc/messages_bot_no_state.go index ea271fb8..2aea421e 100644 --- a/internal/rpc/messages_bot_no_state.go +++ b/internal/rpc/messages_bot_no_state.go @@ -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 { diff --git a/internal/rpc/messages_edit.go b/internal/rpc/messages_edit.go index 8c92807e..816daae4 100644 --- a/internal/rpc/messages_edit.go +++ b/internal/rpc/messages_edit.go @@ -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 { diff --git a/internal/rpc/messages_rich_message_rpc_test.go b/internal/rpc/messages_rich_message_rpc_test.go index e69adf12..f3b9dc5f 100644 --- a/internal/rpc/messages_rich_message_rpc_test.go +++ b/internal/rpc/messages_rich_message_rpc_test.go @@ -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: `

Admin

+
Statusnow
+
More

healthy

+ `, + }) + 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: `

Admin

`, + }); 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"}}}, diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index 5906cff8..be2e03cc 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend return nil, sendErr } } - // rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 - // Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。 + // rich_message(Layer 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 diff --git a/internal/rpc/rich_message_limits.go b/internal/rpc/rich_message_limits.go new file mode 100644 index 00000000..02201ac9 --- /dev/null +++ b/internal/rpc/rich_message_limits.go @@ -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 +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index f930af2e..ab5fd796 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -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 }