feat: sync bot rich messages and inline menus
This commit is contained in:
parent
2965f5d47d
commit
1a2d03f529
24 changed files with 2073 additions and 38 deletions
|
|
@ -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 写入文件。
|
||||
|
|
|
|||
|
|
@ -21,8 +21,14 @@ from aiogram.client.default import DefaultBotProperties
|
|||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import (
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
InputRichMessage,
|
||||
Message,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger("bedolagaformat")
|
||||
|
|
@ -101,6 +107,16 @@ def parse_args() -> argparse.Namespace:
|
|||
help="Send the complete suite proactively before polling",
|
||||
)
|
||||
parser.add_argument("--send-only", action="store_true")
|
||||
parser.add_argument(
|
||||
"--rich-menu",
|
||||
action="store_true",
|
||||
help="also send and edit Bedolaga-style rich HTML/Markdown menus",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rich-only",
|
||||
action="store_true",
|
||||
help="with --send-only, send only the rich menu suite",
|
||||
)
|
||||
parser.add_argument("--drop-pending", action="store_true")
|
||||
parser.add_argument("--polling-timeout", type=int, default=10)
|
||||
parser.add_argument("--marker", default=default_marker())
|
||||
|
|
@ -110,6 +126,8 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.error("missing --token or TELESRV_BOT_TOKEN")
|
||||
if args.send_only and args.send_chat_id is None:
|
||||
parser.error("--send-only requires --send-chat-id")
|
||||
if args.rich_only and (not args.send_only or args.send_chat_id is None):
|
||||
parser.error("--rich-only requires --send-only and --send-chat-id")
|
||||
if not MARKER_RE.fullmatch(args.marker):
|
||||
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
|
||||
if not 0 <= args.polling_timeout <= 50:
|
||||
|
|
@ -151,6 +169,96 @@ async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
|
|||
return message_ids
|
||||
|
||||
|
||||
def rich_menu_html(marker: str, *, include_logo: bool) -> str:
|
||||
"""Build the rich HTML families used by Bedolaga's main menu."""
|
||||
logo = '<img src="https://example.com/bedolaga-logo.png">' if include_logo else ""
|
||||
return (
|
||||
f"{logo}<h4>{marker} Admin</h4>"
|
||||
"<h6>Subscription overview</h6><hr>"
|
||||
"<table bordered striped>"
|
||||
"<tr><th>Status</th><td align=\"right\">Active</td></tr>"
|
||||
"<tr><th>Updated</th><td align=\"right\">"
|
||||
'<tg-time unix="1700000000" format="R">now</tg-time>'
|
||||
"</td></tr></table>"
|
||||
"<details open><summary>Diagnostics</summary>"
|
||||
"<blockquote><code>rich menu online</code></blockquote></details>"
|
||||
"<footer>Choose an option</footer>"
|
||||
)
|
||||
|
||||
|
||||
def rich_menu_markdown(marker: str) -> str:
|
||||
return (
|
||||
f"#### {marker} Markdown menu\n\n"
|
||||
"**Subscription:** Active\n\n"
|
||||
"> Rich Markdown transport is online.\n\n"
|
||||
"`callback keyboard preserved`"
|
||||
)
|
||||
|
||||
|
||||
def rich_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="Balance", callback_data="menu:balance"),
|
||||
InlineKeyboardButton(text="Buy", callback_data="menu:buy"),
|
||||
],
|
||||
[InlineKeyboardButton(text="Info", callback_data="menu:info")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool:
|
||||
message = str(exc).lower()
|
||||
return "webpage_" in message or "media_empty" in message or "media_invalid" in message
|
||||
|
||||
|
||||
async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
|
||||
"""Exercise Bedolaga's send, no-logo retry, keyboard and rich edit path."""
|
||||
markup = rich_menu_keyboard()
|
||||
try:
|
||||
html_message = await bot.send_rich_message(
|
||||
chat_id=chat_id,
|
||||
rich_message=InputRichMessage(
|
||||
html=rich_menu_html(marker, include_logo=True),
|
||||
skip_entity_detection=True,
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
except TelegramBadRequest as exc:
|
||||
if not is_rich_media_retry_error(exc):
|
||||
raise
|
||||
LOG.info("rich logo fetch rejected; retrying the menu without logo")
|
||||
html_message = await bot.send_rich_message(
|
||||
chat_id=chat_id,
|
||||
rich_message=InputRichMessage(
|
||||
html=rich_menu_html(marker, include_logo=False),
|
||||
skip_entity_detection=True,
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
|
||||
markdown_message = await bot.send_rich_message(
|
||||
chat_id=chat_id,
|
||||
rich_message=InputRichMessage(
|
||||
markdown=rich_menu_markdown(marker),
|
||||
skip_entity_detection=True,
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
await bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=html_message.message_id,
|
||||
rich_message=InputRichMessage(
|
||||
html=rich_menu_html(f"{marker} EDITED", include_logo=False),
|
||||
skip_entity_detection=True,
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
ids = [html_message.message_id, markdown_message.message_id]
|
||||
LOG.info("sent rich menu suite chat_id=%s message_ids=%s", chat_id, ids)
|
||||
return ids
|
||||
|
||||
|
||||
def build_dispatcher(marker: str) -> Dispatcher:
|
||||
router = Router(name="telesrv-bedolaga-format")
|
||||
|
||||
|
|
@ -173,6 +281,16 @@ def build_dispatcher(marker: str) -> Dispatcher:
|
|||
ids,
|
||||
)
|
||||
|
||||
@router.message(Command("richdemo"))
|
||||
async def rich_demo(message: Message) -> None:
|
||||
ids = await send_rich_suite(message.bot, message.chat.id, marker)
|
||||
LOG.info(
|
||||
"handled /richdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
ids,
|
||||
)
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.include_router(router)
|
||||
return dispatcher
|
||||
|
|
@ -190,13 +308,16 @@ async def run(args: argparse.Namespace) -> None:
|
|||
args.marker,
|
||||
)
|
||||
if args.send_chat_id is not None:
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import unittest
|
|||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.methods import SendRichMessage
|
||||
from aiogram.types import InputRichMessage
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("demo.py")
|
||||
|
|
@ -47,6 +50,45 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
|
||||
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
|
||||
|
||||
def test_rich_menu_covers_bedolaga_html_and_keyboard(self) -> None:
|
||||
html = demo.rich_menu_html("BEDOLAGA123", include_logo=False)
|
||||
self.assertIn("<h4>BEDOLAGA123 Admin</h4>", html)
|
||||
self.assertIn("<table bordered striped>", html)
|
||||
self.assertIn("<tg-time", html)
|
||||
self.assertIn("<details open>", html)
|
||||
self.assertIn("<footer>", html)
|
||||
markup = demo.rich_menu_keyboard()
|
||||
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "menu:balance")
|
||||
self.assertEqual(markup.inline_keyboard[1][0].callback_data, "menu:info")
|
||||
|
||||
async def test_rich_suite_retries_without_logo_and_edits(self) -> None:
|
||||
bot = AsyncMock()
|
||||
media_error = TelegramBadRequest(
|
||||
method=SendRichMessage(
|
||||
chat_id=1780243200,
|
||||
rich_message=InputRichMessage(html="<p>fixture</p>"),
|
||||
),
|
||||
message="WEBPAGE_MEDIA_EMPTY",
|
||||
)
|
||||
bot.send_rich_message.side_effect = [
|
||||
media_error,
|
||||
SentMessage(21),
|
||||
SentMessage(22),
|
||||
]
|
||||
bot.edit_message_text.return_value = SentMessage(21)
|
||||
|
||||
ids = await demo.send_rich_suite(bot, 1780243200, "BEDOLAGA123")
|
||||
|
||||
self.assertEqual(ids, [21, 22])
|
||||
sends = bot.send_rich_message.await_args_list
|
||||
self.assertEqual(len(sends), 3)
|
||||
self.assertIn("<img", sends[0].kwargs["rich_message"].html)
|
||||
self.assertNotIn("<img", sends[1].kwargs["rich_message"].html)
|
||||
self.assertIsNotNone(sends[2].kwargs["rich_message"].markdown)
|
||||
edit = bot.edit_message_text.await_args
|
||||
self.assertEqual(edit.kwargs["message_id"], 21)
|
||||
self.assertIn("EDITED", edit.kwargs["rich_message"].html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ func cloneDialogMessages(in []domain.Message) []domain.Message {
|
|||
|
||||
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -424,6 +425,7 @@ func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMess
|
|||
|
||||
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
|
|||
|
|
@ -946,6 +946,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ func apiMessageProjectable(msg domain.Message) bool {
|
|||
if msg.Out || msg.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
|
||||
return msg.Body != "" || (msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0) || len(apiMessageMedia(msg.Media, nil, nil)) > 0
|
||||
}
|
||||
|
||||
func apiUser(u domain.User) map[string]any {
|
||||
|
|
@ -320,6 +320,12 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
|
|||
out["entities"] = entities
|
||||
}
|
||||
}
|
||||
if msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0 {
|
||||
var richMessage any
|
||||
if json.Unmarshal(msg.RichMessage.BotAPIProjection, &richMessage) == nil && richMessage != nil {
|
||||
out["rich_message"] = richMessage
|
||||
}
|
||||
}
|
||||
if msg.EditDate > 0 {
|
||||
out["edit_date"] = msg.EditDate
|
||||
}
|
||||
|
|
|
|||
99
internal/botapi/rich_message.go
Normal file
99
internal/botapi/rich_message.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxBotAPIRichSourceBytes = 256 << 10
|
||||
|
||||
func richMessageInputFromAPI(raw string) (domain.BotAPIRichMessageInput, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > maxBotAPIRichSourceBytes {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
var out domain.BotAPIRichMessageInput
|
||||
sources := 0
|
||||
if value, ok := fields["html"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if err := json.Unmarshal(value, &out.HTML); err != nil || out.HTML == "" {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
sources++
|
||||
}
|
||||
if value, ok := fields["markdown"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if err := json.Unmarshal(value, &out.Markdown); err != nil || out.Markdown == "" {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
sources++
|
||||
}
|
||||
if value, ok := fields["blocks"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if len(bytes.TrimSpace(value)) == 0 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
out.BlocksJSON = append([]byte(nil), value...)
|
||||
sources++
|
||||
}
|
||||
if sources != 1 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
if len(out.BlocksJSON) != 0 {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_BLOCKS_UNSUPPORTED")
|
||||
}
|
||||
if value, ok := fields["media"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) && !bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
out.MediaJSON = append([]byte(nil), value...)
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_MEDIA_UNSUPPORTED")
|
||||
}
|
||||
if value, ok := fields["is_rtl"]; ok {
|
||||
if err := json.Unmarshal(value, &out.RTL); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
}
|
||||
if value, ok := fields["skip_entity_detection"]; ok {
|
||||
if err := json.Unmarshal(value, &out.SkipEntityDetection); err != nil {
|
||||
return domain.BotAPIRichMessageInput{}, errors.New("RICH_MESSAGE_INVALID")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func richReplyMessageID(values map[string]string) (int, error) {
|
||||
legacy := apiInt(values["reply_to_message_id"], 0)
|
||||
raw := strings.TrimSpace(values["reply_parameters"])
|
||||
if raw == "" {
|
||||
if legacy < 0 {
|
||||
return 0, errors.New("REPLY_MESSAGE_ID_INVALID")
|
||||
}
|
||||
return legacy, nil
|
||||
}
|
||||
if legacy != 0 {
|
||||
return 0, errors.New("REPLY_PARAMETERS_INVALID")
|
||||
}
|
||||
var payload struct {
|
||||
MessageID int `json:"message_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload.MessageID <= 0 {
|
||||
return 0, errors.New("REPLY_PARAMETERS_INVALID")
|
||||
}
|
||||
return payload.MessageID, nil
|
||||
}
|
||||
|
||||
func apiInt64(raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0, errors.New("VALUE_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -600,6 +600,101 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendRichMessageAndEditPreserveInlineKeyboardAndProjection(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
projection := json.RawMessage(`{"blocks":[{"type":"heading","size":4,"text":"Admin"}],"is_rtl":true}`)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
|
||||
}}}}
|
||||
message := domain.Message{
|
||||
ID: 21, OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Date: 1700000021, Out: true, ReplyMarkup: markup,
|
||||
RichMessage: &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1}, BotAPIProjection: projection},
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
self: domain.User{ID: 1001, FirstName: "Bedolaga", Username: "bedolaga_bot", Bot: true},
|
||||
sendMessage: message,
|
||||
editMessage: message,
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "sendRichMessage", `{
|
||||
"chat_id":2001,
|
||||
"rich_message":{"html":"<h4>Admin</h4>","is_rtl":true,"skip_entity_detection":true},
|
||||
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]},
|
||||
"disable_notification":true,
|
||||
"protect_content":true,
|
||||
"reply_parameters":{"message_id":7}
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sendRichMessage status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !gateway.sendRichCalled || gateway.sendChatID != 2001 || gateway.sendRichInput.HTML != "<h4>Admin</h4>" ||
|
||||
!gateway.sendRichInput.RTL || !gateway.sendRichInput.SkipEntityDetection || !gateway.sendSilent || gateway.sendReplyTo != 7 {
|
||||
t.Fatalf("send rich call = %#v", gateway)
|
||||
}
|
||||
if gateway.sendRichMarkup == nil || len(gateway.sendRichMarkup.Inline) != 1 ||
|
||||
string(gateway.sendRichMarkup.Inline[0][0].Data) != "menu:info" {
|
||||
t.Fatalf("send rich markup = %#v", gateway.sendRichMarkup)
|
||||
}
|
||||
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
|
||||
|
||||
gateway.editMessage.RichMessage.BotAPIProjection = json.RawMessage(`{"blocks":[{"type":"paragraph","text":"Updated"}]}`)
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
|
||||
"chat_id":2001,
|
||||
"message_id":21,
|
||||
"rich_message":{"markdown":"**Updated**","skip_entity_detection":true},
|
||||
"reply_markup":{"inline_keyboard":[[{"text":"Info","callback_data":"menu:info"}]]}
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("editMessageText rich status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !gateway.editRichCalled || gateway.editRichInput.Markdown != "**Updated**" || !gateway.editRichInput.SkipEntityDetection || !gateway.editSetMarkup {
|
||||
t.Fatalf("edit rich call = %#v", gateway)
|
||||
}
|
||||
assertBotAPIRichMenuResponse(t, rec.Body.Bytes(), 21)
|
||||
}
|
||||
|
||||
func TestEditMessageTextRejectsTextAndRichMessageTogether(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{
|
||||
"chat_id":2001,"message_id":21,"text":"plain","rich_message":{"html":"<p>rich</p>"}
|
||||
}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "RICH_MESSAGE_INVALID") {
|
||||
t.Fatalf("edit text+rich status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertBotAPIRichMenuResponse(t *testing.T, raw []byte, messageID int) {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
MessageID int `json:"message_id"`
|
||||
RichMessage struct {
|
||||
Blocks []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"blocks"`
|
||||
} `json:"rich_message"`
|
||||
ReplyMarkup struct {
|
||||
InlineKeyboard [][]struct {
|
||||
CallbackData string `json:"callback_data"`
|
||||
} `json:"inline_keyboard"`
|
||||
} `json:"reply_markup"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatalf("decode rich response: %v", err)
|
||||
}
|
||||
if !response.OK || response.Result.MessageID != messageID || len(response.Result.RichMessage.Blocks) != 1 ||
|
||||
len(response.Result.ReplyMarkup.InlineKeyboard) != 1 || response.Result.ReplyMarkup.InlineKeyboard[0][0].CallbackData != "menu:info" {
|
||||
t.Fatalf("rich response = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
markup := &domain.MessageReplyMarkup{
|
||||
|
|
@ -1329,6 +1424,9 @@ type fakeBotAPIGateway struct {
|
|||
sendSilent bool
|
||||
sendReplyTo int
|
||||
sendMessage domain.Message
|
||||
sendRichCalled bool
|
||||
sendRichInput domain.BotAPIRichMessageInput
|
||||
sendRichMarkup *domain.MessageReplyMarkup
|
||||
sendMediaCalled bool
|
||||
sendMediaKind string
|
||||
sendMediaChatID int64
|
||||
|
|
@ -1342,6 +1440,8 @@ type fakeBotAPIGateway struct {
|
|||
editEntities []domain.MessageEntity
|
||||
editSetMarkup bool
|
||||
editMessage domain.Message
|
||||
editRichCalled bool
|
||||
editRichInput domain.BotAPIRichMessageInput
|
||||
editInlineCalled bool
|
||||
editInlineID domain.BotInlineMessageID
|
||||
editInlineText string
|
||||
|
|
@ -1448,6 +1548,17 @@ func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID i
|
|||
return f.sendMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendRichMessage(_ context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) {
|
||||
f.sendRichCalled = true
|
||||
f.sendBotID = botID
|
||||
f.sendChatID = chatID
|
||||
f.sendRichInput = rich
|
||||
f.sendRichMarkup = replyMarkup
|
||||
f.sendSilent = silent
|
||||
f.sendReplyTo = replyToMessageID
|
||||
return f.sendMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
f.sendMediaCalled = true
|
||||
f.sendMediaKind = kind
|
||||
|
|
@ -1467,6 +1578,13 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
|
|||
return f.editMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditRichMessage(_ context.Context, botID, chatID int64, messageID int, rich domain.BotAPIRichMessageInput, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup) (domain.Message, error) {
|
||||
f.editRichCalled = true
|
||||
f.editRichInput = rich
|
||||
f.editSetMarkup = setReplyMarkup
|
||||
return f.editMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
|
||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||
f.editInlineText = text
|
||||
|
|
@ -1474,6 +1592,12 @@ func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditInlineRichMessage(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, rich domain.BotAPIRichMessageInput, _ bool, _ *domain.MessageReplyMarkup) (bool, error) {
|
||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||
f.editRichInput = rich
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
|
||||
f.deleteCalled = true
|
||||
return true, nil
|
||||
|
|
|
|||
29
internal/domain/botapi_rich_message.go
Normal file
29
internal/domain/botapi_rich_message.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package domain
|
||||
|
||||
// BotAPIRichMessageInput is the protocol-neutral HTTP Bot API input passed to
|
||||
// the RPC edge. Exactly one of HTML, Markdown, or BlocksJSON must be present.
|
||||
// BlocksJSON and MediaJSON are retained in the DTO so unsupported Bot API 10.2
|
||||
// shapes are rejected explicitly at the conversion boundary instead of being
|
||||
// flattened or silently dropped.
|
||||
type BotAPIRichMessageInput struct {
|
||||
HTML string
|
||||
Markdown string
|
||||
BlocksJSON []byte
|
||||
MediaJSON []byte
|
||||
RTL bool
|
||||
SkipEntityDetection bool
|
||||
}
|
||||
|
||||
func (m BotAPIRichMessageInput) SourceCount() int {
|
||||
n := 0
|
||||
if m.HTML != "" {
|
||||
n++
|
||||
}
|
||||
if m.Markdown != "" {
|
||||
n++
|
||||
}
|
||||
if len(m.BlocksJSON) != 0 {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -249,6 +249,112 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessagePrivateSendEditAndPlainReplacement(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Info", Data: []byte("menu:info"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Admin</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage: %v", err)
|
||||
}
|
||||
if sent.ID <= 0 || sent.Pts <= 0 || sent.Body != "" || sent.RichMessage == nil || len(sent.RichMessage.BotAPIProjection) == 0 ||
|
||||
sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "menu:info" {
|
||||
t.Fatalf("sent rich message = %+v", sent)
|
||||
}
|
||||
|
||||
botHistory := privateBotAPIHistory(t, fixture, fixture.bot.ID, fixture.owner.ID)
|
||||
if botHistory.ID != sent.ID || botHistory.RichMessage == nil || botHistory.Body != "" {
|
||||
t.Fatalf("bot rich history = %+v", botHistory)
|
||||
}
|
||||
ownerHistory := privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || len(ownerHistory.RichMessage.BotAPIProjection) == 0 || ownerHistory.ReplyMarkup == nil {
|
||||
t.Fatalf("owner rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated\n\nSubscription: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("edited rich message = %+v projection=%s", edited, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.RichMessage == nil || !strings.Contains(string(ownerHistory.RichMessage.BotAPIProjection), "Updated") {
|
||||
t.Fatalf("owner edited rich history = %+v", ownerHistory)
|
||||
}
|
||||
|
||||
plain, err := fixture.router.BotAPIEditMessageText(fixture.ctx, fixture.bot.ID, fixture.owner.ID, sent.ID, "Classic menu", nil, false, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditMessageText replacing rich: %v", err)
|
||||
}
|
||||
if plain.Body != "Classic menu" || plain.RichMessage != nil {
|
||||
t.Fatalf("plain replacement = %+v", plain)
|
||||
}
|
||||
ownerHistory = privateBotAPIHistory(t, fixture, fixture.owner.ID, fixture.bot.ID)
|
||||
if ownerHistory.Body != "Classic menu" || ownerHistory.RichMessage != nil {
|
||||
t.Fatalf("owner plain replacement history = %+v", ownerHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMessageSupergroupSendAndEdit(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
chatID := -botAPIChannelChatIDBase - fixture.channel.ID
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Status", Data: []byte("channel:status"),
|
||||
}}}}
|
||||
sent, err := fixture.router.BotAPISendRichMessage(fixture.ctx, fixture.bot.ID, chatID, domain.BotAPIRichMessageInput{
|
||||
HTML: `<h4>Group menu</h4><p>Status: active</p>`, SkipEntityDetection: true,
|
||||
}, markup, false, false, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendRichMessage channel: %v", err)
|
||||
}
|
||||
if sent.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || sent.ID <= 0 || sent.Pts <= 0 ||
|
||||
sent.RichMessage == nil || sent.ReplyMarkup == nil || string(sent.ReplyMarkup.Inline[0][0].Data) != "channel:status" {
|
||||
t.Fatalf("sent channel rich message = %+v", sent)
|
||||
}
|
||||
history, err := fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil || history.Messages[0].Body != "" {
|
||||
t.Fatalf("channel rich history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
|
||||
edited, err := fixture.router.BotAPIEditRichMessage(fixture.ctx, fixture.bot.ID, chatID, sent.ID, domain.BotAPIRichMessageInput{
|
||||
Markdown: "## Updated group menu\n\nStatus: active", SkipEntityDetection: true,
|
||||
}, true, markup)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIEditRichMessage channel: %v", err)
|
||||
}
|
||||
if edited.RichMessage == nil || edited.Body != "" || edited.EditDate == 0 || edited.Pts <= sent.Pts ||
|
||||
!strings.Contains(string(edited.RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel rich message = %+v sent_pts=%d projection=%s", edited, sent.Pts, edited.RichMessage.BotAPIProjection)
|
||||
}
|
||||
history, err = fixture.channels.GetHistory(fixture.ctx, fixture.owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: fixture.channel.ID, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].RichMessage == nil ||
|
||||
!strings.Contains(string(history.Messages[0].RichMessage.BotAPIProjection), "Updated group") {
|
||||
t.Fatalf("edited channel history = %+v err=%v", history.Messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func privateBotAPIHistory(t *testing.T, fixture botAPIReceiveFixture, ownerID, peerID int64) domain.Message {
|
||||
t.Helper()
|
||||
history, err := fixture.messages.GetHistory(fixture.ctx, ownerID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, Limit: 1,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("GetHistory owner=%d peer=%d len=%d err=%v", ownerID, peerID, len(history.Messages), err)
|
||||
}
|
||||
return history.Messages[0]
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
|
|
|
|||
305
internal/rpc/botapi_rich_message.go
Normal file
305
internal/rpc/botapi_rich_message.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
richbuilder "github.com/iamxvbaba/td/telegram/message/rich"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
botAPIRichSentinelScheme = "telesrv-rich"
|
||||
botAPIRichDateMaxUnix = int64(1<<31 - 1)
|
||||
)
|
||||
|
||||
type botAPIHTMLTableSpec struct {
|
||||
bordered bool
|
||||
striped bool
|
||||
cells []botAPIHTMLTableCellSpec
|
||||
}
|
||||
|
||||
type botAPIHTMLTableCellSpec struct {
|
||||
align string
|
||||
valign string
|
||||
}
|
||||
|
||||
func tgInputRichMessageFromBotAPI(input domain.BotAPIRichMessageInput) (tg.InputRichMessageClass, error) {
|
||||
if input.SourceCount() != 1 || len(input.BlocksJSON) != 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
if len(input.MediaJSON) != 0 {
|
||||
return nil, richMessageMediaUnsupportedErr()
|
||||
}
|
||||
if input.HTML != "" {
|
||||
return &tg.InputRichMessageHTML{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, HTML: input.HTML,
|
||||
}, nil
|
||||
}
|
||||
if input.Markdown != "" {
|
||||
return &tg.InputRichMessageMarkdown{
|
||||
Rtl: input.RTL, Noautolink: input.SkipEntityDetection, Markdown: input.Markdown,
|
||||
}, nil
|
||||
}
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
|
||||
func parseBotAPIRichHTML(source string) ([]tg.PageBlockClass, error) {
|
||||
doc, err := html.Parse(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
tables := make([]botAPIHTMLTableSpec, 0)
|
||||
var transform func(*html.Node) error
|
||||
transform = func(node *html.Node) error {
|
||||
if node.Type == html.ElementNode {
|
||||
switch node.Data {
|
||||
case "img", "video", "audio", "tg-map", "tg-collage", "tg-slideshow":
|
||||
// The current local blob backend cannot materialize an arbitrary
|
||||
// rich HTML media URL atomically. Fail explicitly so Bedolaga's
|
||||
// documented one-shot no-logo retry is used instead of losing media.
|
||||
return webpageMediaEmptyErr()
|
||||
case "tg-time":
|
||||
unixTime, err := strconv.ParseInt(htmlNodeAttr(node, "unix"), 10, 64)
|
||||
if err != nil || unixTime <= 0 || unixTime > botAPIRichDateMaxUnix {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
format := htmlNodeAttr(node, "format")
|
||||
if _, ok := botAPIRichDateFlags(format); !ok {
|
||||
return richMessageDateInvalidErr()
|
||||
}
|
||||
node.Data = "a"
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: fmt.Sprintf("%s://time?unix=%d&format=%s", botAPIRichSentinelScheme, unixTime, url.QueryEscape(format))}}
|
||||
case "footer":
|
||||
node.Data = "p"
|
||||
node.Attr = nil
|
||||
anchor := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{{Key: "href", Val: botAPIRichSentinelScheme + "://footer"}}}
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
anchor.AppendChild(child)
|
||||
child = next
|
||||
}
|
||||
node.AppendChild(anchor)
|
||||
case "table":
|
||||
tables = append(tables, botAPIHTMLTableSpecFromNode(node))
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if err := transform(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := transform(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized bytes.Buffer
|
||||
if err := html.Render(&normalized, doc); err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
blocks, err := richbuilder.ParseHTML(strings.NewReader(normalized.String()))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
postProcessBotAPIRichHTML(blocks, tables)
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func parseBotAPIRichMarkdown(source string) ([]tg.PageBlockClass, error) {
|
||||
blocks, err := richbuilder.ParseMarkdown(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func botAPIHTMLTableSpecFromNode(table *html.Node) botAPIHTMLTableSpec {
|
||||
spec := botAPIHTMLTableSpec{bordered: htmlNodeHasAttr(table, "bordered"), striped: htmlNodeHasAttr(table, "striped")}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && (child.Data == "td" || child.Data == "th") {
|
||||
spec.cells = append(spec.cells, botAPIHTMLTableCellSpec{
|
||||
align: strings.ToLower(htmlNodeAttr(child, "align")), valign: strings.ToLower(htmlNodeAttr(child, "valign")),
|
||||
})
|
||||
}
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(table)
|
||||
return spec
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichHTML(blocks []tg.PageBlockClass, tables []botAPIHTMLTableSpec) {
|
||||
tableIndex := 0
|
||||
var visit func([]tg.PageBlockClass)
|
||||
visit = func(items []tg.PageBlockClass) {
|
||||
for index, block := range items {
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
if footer, ok := botAPIRichFooterText(value.Text); ok {
|
||||
items[index] = &tg.PageBlockFooter{Text: postProcessBotAPIRichText(footer)}
|
||||
} else {
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
case *tg.PageBlockHeading1:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockFooter:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.PageBlockBlockquote:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
value.Caption = postProcessBotAPIRichText(value.Caption)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockDetails:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
visit(value.Blocks)
|
||||
case *tg.PageBlockTable:
|
||||
value.Title = postProcessBotAPIRichText(value.Title)
|
||||
if tableIndex < len(tables) {
|
||||
spec := tables[tableIndex]
|
||||
tableIndex++
|
||||
value.Bordered, value.Striped = spec.bordered, spec.striped
|
||||
cellIndex := 0
|
||||
for rowIndex := range value.Rows {
|
||||
for columnIndex := range value.Rows[rowIndex].Cells {
|
||||
cell := &value.Rows[rowIndex].Cells[columnIndex]
|
||||
cell.Text = postProcessBotAPIRichText(cell.Text)
|
||||
if cellIndex < len(spec.cells) {
|
||||
cellSpec := spec.cells[cellIndex]
|
||||
cell.AlignCenter = cellSpec.align == "center"
|
||||
cell.AlignRight = cellSpec.align == "right"
|
||||
cell.ValignMiddle = cellSpec.valign == "middle"
|
||||
cell.ValignBottom = cellSpec.valign == "bottom"
|
||||
}
|
||||
cellIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
}
|
||||
|
||||
func postProcessBotAPIRichText(text tg.RichTextClass) tg.RichTextClass {
|
||||
switch value := text.(type) {
|
||||
case *tg.TextConcat:
|
||||
for i := range value.Texts {
|
||||
value.Texts[i] = postProcessBotAPIRichText(value.Texts[i])
|
||||
}
|
||||
case *tg.TextBold:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextItalic:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextStrike:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextFixed:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMarked:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextURL:
|
||||
parsed, err := url.Parse(value.URL)
|
||||
if err == nil && parsed.Scheme == botAPIRichSentinelScheme && parsed.Host == "time" {
|
||||
unixTime, unixErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
|
||||
flags, ok := botAPIRichDateFlags(parsed.Query().Get("format"))
|
||||
if unixErr == nil && ok {
|
||||
return richbuilder.Date(postProcessBotAPIRichText(value.Text), int(unixTime), flags)
|
||||
}
|
||||
}
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextEmail:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextPhone:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
case *tg.TextDate:
|
||||
value.Text = postProcessBotAPIRichText(value.Text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func botAPIRichFooterText(text tg.RichTextClass) (tg.RichTextClass, bool) {
|
||||
link, ok := text.(*tg.TextURL)
|
||||
if !ok || link.URL != botAPIRichSentinelScheme+"://footer" {
|
||||
return nil, false
|
||||
}
|
||||
return link.Text, true
|
||||
}
|
||||
|
||||
func botAPIRichDateFlags(format string) (richbuilder.DateFlags, bool) {
|
||||
if format == "r" || format == "R" {
|
||||
return richbuilder.DateFlags{Relative: true}, true
|
||||
}
|
||||
var flags richbuilder.DateFlags
|
||||
if format == "" {
|
||||
return flags, false
|
||||
}
|
||||
for _, value := range format {
|
||||
switch value {
|
||||
case 't':
|
||||
flags.ShortTime = true
|
||||
case 'T':
|
||||
flags.LongTime = true
|
||||
case 'd':
|
||||
flags.ShortDate = true
|
||||
case 'D':
|
||||
flags.LongDate = true
|
||||
case 'w', 'W':
|
||||
flags.DayOfWeek = true
|
||||
default:
|
||||
return richbuilder.DateFlags{}, false
|
||||
}
|
||||
}
|
||||
return flags, true
|
||||
}
|
||||
|
||||
func htmlNodeAttr(node *html.Node, key string) string {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return attribute.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func htmlNodeHasAttr(node *html.Node, key string) bool {
|
||||
for _, attribute := range node.Attr {
|
||||
if attribute.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
503
internal/rpc/botapi_rich_projection.go
Normal file
503
internal/rpc/botapi_rich_projection.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func botAPIRichMessageProjection(blocks []tg.PageBlockClass, rtl bool) ([]byte, error) {
|
||||
projected, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(projected) == 0 {
|
||||
return nil, richMessageInvalidErr()
|
||||
}
|
||||
out := map[string]any{"blocks": projected}
|
||||
if rtl {
|
||||
out["is_rtl"] = true
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func botAPIRichBlocks(blocks []tg.PageBlockClass) ([]any, error) {
|
||||
out := make([]any, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
projected, err := botAPIRichBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projected != nil {
|
||||
out = append(out, projected)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlock(block tg.PageBlockClass) (map[string]any, error) {
|
||||
textBlock := func(kind string, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
heading := func(size int, text tg.RichTextClass) (map[string]any, error) {
|
||||
value, err := botAPIRichText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": "heading", "text": value, "size": size}, nil
|
||||
}
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockParagraph:
|
||||
return textBlock("paragraph", value.Text)
|
||||
case *tg.PageBlockTitle:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockSubtitle:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeader:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockSubheader:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockKicker:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockHeading1:
|
||||
return heading(1, value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
return heading(2, value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
return heading(3, value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
return heading(4, value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
return heading(5, value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
return heading(6, value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
out, err := textBlock("pre", value.Text)
|
||||
if err == nil && value.Language != "" {
|
||||
out["language"] = value.Language
|
||||
}
|
||||
return out, err
|
||||
case *tg.PageBlockFooter:
|
||||
return textBlock("footer", value.Text)
|
||||
case *tg.PageBlockDivider:
|
||||
return map[string]any{"type": "divider"}, nil
|
||||
case *tg.PageBlockMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.PageBlockAnchor:
|
||||
return map[string]any{"type": "anchor", "name": value.Name}, nil
|
||||
case *tg.PageBlockDetails:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := botAPIRichText(value.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "details", "summary": summary, "blocks": blocks}
|
||||
if value.Open {
|
||||
out["is_open"] = true
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquote:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": []any{map[string]any{"type": "paragraph", "text": text}}}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": "blockquote", "blocks": blocks}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockPullquote:
|
||||
out, err := textBlock("pullquote", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !botAPIRichTextEmpty(value.Caption) {
|
||||
credit, err := botAPIRichText(value.Caption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["credit"] = credit
|
||||
}
|
||||
return out, nil
|
||||
case *tg.PageBlockList:
|
||||
return botAPIUnorderedRichList(value)
|
||||
case *tg.PageBlockOrderedList:
|
||||
return botAPIOrderedRichList(value)
|
||||
case *tg.PageBlockTable:
|
||||
return botAPIRichTable(value)
|
||||
case *tg.PageBlockCollage:
|
||||
return botAPIRichBlockCollection("collage", value.Items, value.Caption)
|
||||
case *tg.PageBlockSlideshow:
|
||||
return botAPIRichBlockCollection("slideshow", value.Items, value.Caption)
|
||||
case *tg.PageBlockCover:
|
||||
return botAPIRichBlock(value.Cover)
|
||||
case *tg.PageBlockThinking:
|
||||
return textBlock("thinking", value.Text)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIUnorderedRichList(list *tg.PageBlockList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for _, raw := range list.Items {
|
||||
item := map[string]any{"label": "•"}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
if value.Checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if value.Checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
case *tg.PageListItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIOrderedRichList(list *tg.PageBlockOrderedList) (map[string]any, error) {
|
||||
items := make([]any, 0, len(list.Items))
|
||||
for index, raw := range list.Items {
|
||||
item := map[string]any{"label": strconv.Itoa(index + 1)}
|
||||
switch value := raw.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
text, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = []any{map[string]any{"type": "paragraph", "text": text}}
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
blocks, err := botAPIRichBlocks(value.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["blocks"] = blocks
|
||||
botAPIFillOrderedListItem(item, value.Num, value.Value, value.Type, value.Checkbox, value.Checked)
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"type": "list", "items": items}, nil
|
||||
}
|
||||
|
||||
func botAPIFillOrderedListItem(item map[string]any, label string, value int, kind string, checkbox, checked bool) {
|
||||
if label != "" {
|
||||
item["label"] = label
|
||||
}
|
||||
if value != 0 {
|
||||
item["value"] = value
|
||||
}
|
||||
if kind != "" {
|
||||
item["type"] = kind
|
||||
}
|
||||
if checkbox {
|
||||
item["has_checkbox"] = true
|
||||
if checked {
|
||||
item["is_checked"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichTable(table *tg.PageBlockTable) (map[string]any, error) {
|
||||
rows := make([]any, 0, len(table.Rows))
|
||||
for _, row := range table.Rows {
|
||||
cells := make([]any, 0, len(row.Cells))
|
||||
for _, cell := range row.Cells {
|
||||
item := map[string]any{"align": "left", "valign": "top"}
|
||||
if !botAPIRichTextEmpty(cell.Text) {
|
||||
text, err := botAPIRichText(cell.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["text"] = text
|
||||
}
|
||||
if cell.Header {
|
||||
item["is_header"] = true
|
||||
}
|
||||
if cell.Colspan > 1 {
|
||||
item["colspan"] = cell.Colspan
|
||||
}
|
||||
if cell.Rowspan > 1 {
|
||||
item["rowspan"] = cell.Rowspan
|
||||
}
|
||||
if cell.AlignCenter {
|
||||
item["align"] = "center"
|
||||
} else if cell.AlignRight {
|
||||
item["align"] = "right"
|
||||
}
|
||||
if cell.ValignMiddle {
|
||||
item["valign"] = "middle"
|
||||
} else if cell.ValignBottom {
|
||||
item["valign"] = "bottom"
|
||||
}
|
||||
cells = append(cells, item)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
out := map[string]any{"type": "table", "cells": rows}
|
||||
if table.Bordered {
|
||||
out["is_bordered"] = true
|
||||
}
|
||||
if table.Striped {
|
||||
out["is_striped"] = true
|
||||
}
|
||||
if !botAPIRichTextEmpty(table.Title) {
|
||||
caption, err := botAPIRichText(table.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = caption
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichBlockCollection(kind string, blocks []tg.PageBlockClass, caption tg.PageCaption) (map[string]any, error) {
|
||||
items, err := botAPIRichBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"type": kind, "blocks": items}
|
||||
if !botAPIRichTextEmpty(caption.Text) || !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected := map[string]any{}
|
||||
if !botAPIRichTextEmpty(caption.Text) {
|
||||
projected["text"], err = botAPIRichText(caption.Text)
|
||||
}
|
||||
if err == nil && !botAPIRichTextEmpty(caption.Credit) {
|
||||
projected["credit"], err = botAPIRichText(caption.Credit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["caption"] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIRichText(text tg.RichTextClass) (any, error) {
|
||||
wrapped := func(kind string, child tg.RichTextClass) (any, error) {
|
||||
value, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"type": kind, "text": value}, nil
|
||||
}
|
||||
valued := func(kind, field, value string, child tg.RichTextClass) (any, error) {
|
||||
out, err := wrapped(kind, child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)[field] = value
|
||||
return out, nil
|
||||
}
|
||||
switch value := text.(type) {
|
||||
case nil, *tg.TextEmpty:
|
||||
return "", nil
|
||||
case *tg.TextPlain:
|
||||
return value.Text, nil
|
||||
case *tg.TextConcat:
|
||||
items := make([]any, 0, len(value.Texts))
|
||||
for _, child := range value.Texts {
|
||||
item, err := botAPIRichText(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
case *tg.TextBold:
|
||||
return wrapped("bold", value.Text)
|
||||
case *tg.TextItalic:
|
||||
return wrapped("italic", value.Text)
|
||||
case *tg.TextUnderline:
|
||||
return wrapped("underline", value.Text)
|
||||
case *tg.TextStrike:
|
||||
return wrapped("strikethrough", value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
return wrapped("spoiler", value.Text)
|
||||
case *tg.TextFixed:
|
||||
return wrapped("code", value.Text)
|
||||
case *tg.TextSubscript:
|
||||
return wrapped("subscript", value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
return wrapped("superscript", value.Text)
|
||||
case *tg.TextMarked:
|
||||
return wrapped("marked", value.Text)
|
||||
case *tg.TextDate:
|
||||
out, err := wrapped("date_time", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := out.(map[string]any)
|
||||
item["unix_time"] = value.Date
|
||||
item["date_time_format"] = botAPIRichDateFormat(value)
|
||||
return item, nil
|
||||
case *tg.TextCustomEmoji:
|
||||
return map[string]any{"type": "custom_emoji", "custom_emoji_id": strconv.FormatInt(value.DocumentID, 10), "alternative_text": value.Alt}, nil
|
||||
case *tg.TextMath:
|
||||
return map[string]any{"type": "mathematical_expression", "expression": value.Source}, nil
|
||||
case *tg.TextURL:
|
||||
if strings.HasPrefix(value.URL, "#") {
|
||||
return valued("anchor_link", "anchor_name", strings.TrimPrefix(value.URL, "#"), value.Text)
|
||||
}
|
||||
return valued("url", "url", value.URL, value.Text)
|
||||
case *tg.TextEmail:
|
||||
return valued("email_address", "email_address", value.Email, value.Text)
|
||||
case *tg.TextPhone:
|
||||
return valued("phone_number", "phone_number", value.Phone, value.Text)
|
||||
case *tg.TextBankCard:
|
||||
return valued("bank_card_number", "bank_card_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMention:
|
||||
return valued("mention", "username", strings.TrimPrefix(botAPIRichPlainText(value.Text), "@"), value.Text)
|
||||
case *tg.TextHashtag:
|
||||
return valued("hashtag", "hashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "#"), value.Text)
|
||||
case *tg.TextCashtag:
|
||||
return valued("cashtag", "cashtag", strings.TrimPrefix(botAPIRichPlainText(value.Text), "$"), value.Text)
|
||||
case *tg.TextBotCommand:
|
||||
return valued("bot_command", "bot_command", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoURL:
|
||||
return valued("url", "url", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoEmail:
|
||||
return valued("email_address", "email_address", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextAutoPhone:
|
||||
return valued("phone_number", "phone_number", botAPIRichPlainText(value.Text), value.Text)
|
||||
case *tg.TextMentionName:
|
||||
out, err := wrapped("text_mention", value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.(map[string]any)["user"] = map[string]any{"id": value.UserID, "is_bot": false, "first_name": "User " + strconv.FormatInt(value.UserID, 10)}
|
||||
return out, nil
|
||||
case *tg.TextAnchor:
|
||||
anchor := map[string]any{"type": "anchor", "name": value.Name}
|
||||
if botAPIRichTextEmpty(value.Text) {
|
||||
return anchor, nil
|
||||
}
|
||||
inner, err := botAPIRichText(value.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []any{anchor, inner}, nil
|
||||
default:
|
||||
return nil, errors.New("RICH_MESSAGE_PROJECTION_UNSUPPORTED")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRichDateFormat(date *tg.TextDate) string {
|
||||
if date.Relative {
|
||||
return "r"
|
||||
}
|
||||
var out strings.Builder
|
||||
if date.ShortTime {
|
||||
out.WriteByte('t')
|
||||
}
|
||||
if date.LongTime {
|
||||
out.WriteByte('T')
|
||||
}
|
||||
if date.ShortDate {
|
||||
out.WriteByte('d')
|
||||
}
|
||||
if date.LongDate {
|
||||
out.WriteByte('D')
|
||||
}
|
||||
if date.DayOfWeek {
|
||||
out.WriteByte('w')
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func botAPIRichTextEmpty(text tg.RichTextClass) bool {
|
||||
return text == nil || botAPIRichPlainText(text) == ""
|
||||
}
|
||||
|
||||
func botAPIRichPlainText(text tg.RichTextClass) string {
|
||||
var out strings.Builder
|
||||
var walk func(tg.RichTextClass)
|
||||
walk = func(value tg.RichTextClass) {
|
||||
switch value := value.(type) {
|
||||
case *tg.TextPlain:
|
||||
out.WriteString(value.Text)
|
||||
case *tg.TextConcat:
|
||||
for _, child := range value.Texts {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.TextBold:
|
||||
walk(value.Text)
|
||||
case *tg.TextItalic:
|
||||
walk(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
walk(value.Text)
|
||||
case *tg.TextStrike:
|
||||
walk(value.Text)
|
||||
case *tg.TextFixed:
|
||||
walk(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
walk(value.Text)
|
||||
case *tg.TextMarked:
|
||||
walk(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
walk(value.Text)
|
||||
case *tg.TextURL:
|
||||
walk(value.Text)
|
||||
case *tg.TextEmail:
|
||||
walk(value.Text)
|
||||
case *tg.TextPhone:
|
||||
walk(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
walk(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
walk(value.Text)
|
||||
case *tg.TextDate:
|
||||
walk(value.Text)
|
||||
case *tg.TextCustomEmoji:
|
||||
out.WriteString(value.Alt)
|
||||
}
|
||||
}
|
||||
walk(text)
|
||||
return out.String()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
|
|
@ -248,6 +251,108 @@ func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
|
|||
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
|
||||
}
|
||||
|
||||
func TestBotAPIRichHTMLParsesBedolagaMenuStructures(t *testing.T) {
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
|
||||
Rtl: true,
|
||||
Noautolink: true,
|
||||
HTML: `<h4>Admin</h4>
|
||||
<table bordered striped><tr><th>Status</th><td align="right" valign="bottom"><tg-time unix="1700000000" format="R">now</tg-time></td></tr></table>
|
||||
<details open><summary>More</summary><p><blockquote><code>healthy</code></blockquote></p></details>
|
||||
<footer>Choose an option</footer>`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse Bedolaga rich HTML: %v", err)
|
||||
}
|
||||
decoded, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("decode rich HTML: %v", err)
|
||||
}
|
||||
if !decoded.Rtl {
|
||||
t.Fatal("rich HTML lost is_rtl")
|
||||
}
|
||||
var heading, table, details, footer bool
|
||||
for _, block := range decoded.Blocks {
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockHeading4:
|
||||
heading = true
|
||||
case *tg.PageBlockTable:
|
||||
table = true
|
||||
if !value.Bordered || !value.Striped || len(value.Rows) != 1 || len(value.Rows[0].Cells) != 2 {
|
||||
t.Fatalf("table shape = %+v", value)
|
||||
}
|
||||
cell := value.Rows[0].Cells[1]
|
||||
if !cell.AlignRight || !cell.ValignBottom || !richTextContainsDate(cell.Text, 1700000000) {
|
||||
t.Fatalf("table date/alignment = %+v", cell)
|
||||
}
|
||||
case *tg.PageBlockDetails:
|
||||
details = value.Open && len(value.Blocks) != 0
|
||||
case *tg.PageBlockFooter:
|
||||
footer = true
|
||||
}
|
||||
}
|
||||
if !heading || !table || !details || !footer {
|
||||
t.Fatalf("parsed blocks heading=%v table=%v details=%v footer=%v: %#v", heading, table, details, footer, decoded.Blocks)
|
||||
}
|
||||
var projected struct {
|
||||
RTL bool `json:"is_rtl"`
|
||||
Blocks []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"blocks"`
|
||||
}
|
||||
if err := json.Unmarshal(rich.BotAPIProjection, &projected); err != nil {
|
||||
t.Fatalf("decode Bot API projection: %v", err)
|
||||
}
|
||||
if !projected.RTL || len(projected.Blocks) != len(decoded.Blocks) {
|
||||
t.Fatalf("Bot API projection = %s", rich.BotAPIProjection)
|
||||
}
|
||||
|
||||
if _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{
|
||||
HTML: `<h4>Admin</h4><img src="https://example.test/logo.png">`,
|
||||
}); err == nil || !tgerr.Is(err, "WEBPAGE_MEDIA_EMPTY") {
|
||||
t.Fatalf("HTML media err = %v, want WEBPAGE_MEDIA_EMPTY for Bedolaga no-logo retry", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIRichMarkdownParsesAndProjects(t *testing.T) {
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageMarkdown{
|
||||
Markdown: "# Bedolaga\n\n**Subscription:** active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse rich Markdown: %v", err)
|
||||
}
|
||||
decoded, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("decode rich Markdown: %v", err)
|
||||
}
|
||||
if len(decoded.Blocks) < 2 || len(rich.BotAPIProjection) == 0 || !strings.Contains(string(rich.BotAPIProjection), "Bedolaga") {
|
||||
t.Fatalf("Markdown decoded=%#v projection=%s", decoded.Blocks, rich.BotAPIProjection)
|
||||
}
|
||||
}
|
||||
|
||||
func richTextContainsDate(text tg.RichTextClass, want int) bool {
|
||||
switch value := text.(type) {
|
||||
case *tg.TextDate:
|
||||
return value.Date == want
|
||||
case *tg.TextConcat:
|
||||
for _, child := range value.Texts {
|
||||
if richTextContainsDate(child, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *tg.TextBold:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextItalic:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextFixed:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
case *tg.TextURL:
|
||||
return richTextContainsDate(value.Text, want)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
|
@ -417,7 +522,7 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich",
|
||||
Message: "",
|
||||
RandomID: 7001,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
|
|
@ -642,7 +747,7 @@ func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich",
|
||||
Message: "",
|
||||
RandomID: 7002,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
|
|
@ -681,7 +786,7 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) {
|
|||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Message: "rich+photo",
|
||||
Message: "",
|
||||
RandomID: 7003,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
215
internal/rpc/rich_message_limits.go
Normal file
215
internal/rpc/rich_message_limits.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
richMessageLengthLimit = 32768
|
||||
richMessageMaxBlocks = 500
|
||||
richMessageMaxDepth = 16
|
||||
richMessageMaxMedia = 50
|
||||
richMessageMaxTableCols = 20
|
||||
)
|
||||
|
||||
type richMessageMetrics struct {
|
||||
textLength int
|
||||
blocks int
|
||||
depth int
|
||||
media int
|
||||
tableCols int
|
||||
}
|
||||
|
||||
func validateRichMessageBlocks(blocks []tg.PageBlockClass) error {
|
||||
metrics := richMessageMetrics{}
|
||||
collectRichMessageBlockMetrics(blocks, 1, &metrics)
|
||||
if metrics.textLength > richMessageLengthLimit || metrics.blocks > richMessageMaxBlocks ||
|
||||
metrics.depth > richMessageMaxDepth || metrics.media > richMessageMaxMedia || metrics.tableCols > richMessageMaxTableCols {
|
||||
return richMessageTooLongErr()
|
||||
}
|
||||
if metrics.blocks == 0 || metrics.textLength == 0 && metrics.media == 0 {
|
||||
return richMessageInvalidErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metrics *richMessageMetrics) {
|
||||
if depth > metrics.depth {
|
||||
metrics.depth = depth
|
||||
}
|
||||
for _, block := range blocks {
|
||||
metrics.blocks++
|
||||
switch value := block.(type) {
|
||||
case *tg.PageBlockTitle:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockSubtitle:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeader:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockSubheader:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockKicker:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockParagraph:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockPreformatted:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockFooter:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading1:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading2:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading3:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading4:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading5:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockHeading6:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockMath:
|
||||
metrics.textLength += utf16StringLength(value.Source)
|
||||
case *tg.PageBlockThinking:
|
||||
metrics.textLength += richTextUTF16Length(value.Text)
|
||||
case *tg.PageBlockAuthorDate:
|
||||
metrics.textLength += richTextUTF16Length(value.Author)
|
||||
case *tg.PageBlockBlockquote:
|
||||
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
|
||||
case *tg.PageBlockPullquote:
|
||||
metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption)
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockDetails:
|
||||
metrics.textLength += richTextUTF16Length(value.Title)
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range value.Items {
|
||||
switch item := item.(type) {
|
||||
case *tg.PageListItemText:
|
||||
metrics.textLength += richTextUTF16Length(item.Text)
|
||||
case *tg.PageListItemBlocks:
|
||||
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockOrderedList:
|
||||
for _, item := range value.Items {
|
||||
switch item := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
metrics.textLength += richTextUTF16Length(item.Text)
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockTable:
|
||||
metrics.textLength += richTextUTF16Length(value.Title)
|
||||
for _, row := range value.Rows {
|
||||
columns := 0
|
||||
for _, cell := range row.Cells {
|
||||
metrics.textLength += richTextUTF16Length(cell.Text)
|
||||
if cell.Colspan > 1 {
|
||||
columns += cell.Colspan
|
||||
} else {
|
||||
columns++
|
||||
}
|
||||
}
|
||||
if columns > metrics.tableCols {
|
||||
metrics.tableCols = columns
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCollage:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
|
||||
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
|
||||
case *tg.PageBlockSlideshow:
|
||||
metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit)
|
||||
collectRichMessageBlockMetrics(value.Items, depth+1, metrics)
|
||||
case *tg.PageBlockCover:
|
||||
collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics)
|
||||
case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio:
|
||||
metrics.media++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func richTextUTF16Length(text tg.RichTextClass) int {
|
||||
switch value := text.(type) {
|
||||
case nil, *tg.TextEmpty:
|
||||
return 0
|
||||
case *tg.TextPlain:
|
||||
return utf16StringLength(value.Text)
|
||||
case *tg.TextConcat:
|
||||
total := 0
|
||||
for _, child := range value.Texts {
|
||||
total += richTextUTF16Length(child)
|
||||
}
|
||||
return total
|
||||
case *tg.TextBold:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextItalic:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextUnderline:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextStrike:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextFixed:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSubscript:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSuperscript:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMarked:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextSpoiler:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextURL:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMention:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextHashtag:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextBotCommand:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextCashtag:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoURL:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoEmail:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAutoPhone:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextBankCard:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextEmail:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextPhone:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextAnchor:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextMentionName:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextDate:
|
||||
return richTextUTF16Length(value.Text)
|
||||
case *tg.TextCustomEmoji:
|
||||
return utf16StringLength(value.Alt)
|
||||
case *tg.TextMath:
|
||||
return utf16StringLength(value.Source)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func utf16StringLength(value string) int {
|
||||
length := 0
|
||||
for _, r := range value {
|
||||
length++
|
||||
if r > utf8.RuneSelf && r > 0xffff {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue