diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md index 32dfccb4..155cff04 100644 --- a/cmd/bots/bedolagaformat/README.md +++ b/cmd/bots/bedolagaformat/README.md @@ -14,6 +14,13 @@ telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legac MarkdownV2,用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update → TDesktop` 链路。 +`/richdemo` 进一步复刻 Bedolaga 的 rich menu:调用 `sendRichMessage` 发送 HTML 与 +Markdown `InputRichMessage`,携带 inline callback keyboard,再通过 +`editMessageText.rich_message` 编辑 HTML 菜单。HTML 样例覆盖 heading、divider、 +bordered/striped table、`tg-time`、details、blockquote、code 与 footer。第一次请求 +故意带远程 logo;当前本地 blob backend 返回 `WEBPAGE_MEDIA_EMPTY` 后,demo 按 +Bedolaga 的既有策略自动去掉 logo 重试,正文与按钮不会降级成 classic menu。 + ## 安装 建议使用虚拟环境,token 只通过环境变量传入: @@ -34,6 +41,7 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081" ```text /start /formatdemo +/richdemo ``` 也可以不启动 polling,直接向指定私聊发送三条格式测试消息: @@ -46,5 +54,16 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081" --marker BEDOLAGA-LOCAL-VERIFY ``` +只主动验证 rich menu(HTML + Markdown + 按钮 + 编辑 + logo fallback): + +```powershell +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py ` + --send-only ` + --rich-only ` + --send-chat-id 1780243200 ` + --marker BEDOLAGA-RICH-VERIFY +``` + `--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会 把 token 写入文件。 diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py index a90746e5..d339a8db 100644 --- a/cmd/bots/bedolagaformat/demo.py +++ b/cmd/bots/bedolagaformat/demo.py @@ -21,8 +21,14 @@ from aiogram.client.default import DefaultBotProperties from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TelegramAPIServer from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest from aiogram.filters import Command, CommandStart -from aiogram.types import Message +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + InputRichMessage, + Message, +) LOG = logging.getLogger("bedolagaformat") @@ -101,6 +107,16 @@ def parse_args() -> argparse.Namespace: help="Send the complete suite proactively before polling", ) parser.add_argument("--send-only", action="store_true") + parser.add_argument( + "--rich-menu", + action="store_true", + help="also send and edit Bedolaga-style rich HTML/Markdown menus", + ) + parser.add_argument( + "--rich-only", + action="store_true", + help="with --send-only, send only the rich menu suite", + ) parser.add_argument("--drop-pending", action="store_true") parser.add_argument("--polling-timeout", type=int, default=10) parser.add_argument("--marker", default=default_marker()) @@ -110,6 +126,8 @@ def parse_args() -> argparse.Namespace: parser.error("missing --token or TELESRV_BOT_TOKEN") if args.send_only and args.send_chat_id is None: parser.error("--send-only requires --send-chat-id") + if args.rich_only and (not args.send_only or args.send_chat_id is None): + parser.error("--rich-only requires --send-only and --send-chat-id") if not MARKER_RE.fullmatch(args.marker): parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens") if not 0 <= args.polling_timeout <= 50: @@ -151,6 +169,96 @@ async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]: return message_ids +def rich_menu_html(marker: str, *, include_logo: bool) -> str: + """Build the rich HTML families used by Bedolaga's main menu.""" + logo = '' if include_logo else "" + return ( + f"{logo}

{marker} Admin

" + "
Subscription overview

" + "" + "" + "
StatusActive
Updated" + 'now' + "
" + "
Diagnostics" + "
rich menu online
" + "" + ) + + +def rich_menu_markdown(marker: str) -> str: + return ( + f"#### {marker} Markdown menu\n\n" + "**Subscription:** Active\n\n" + "> Rich Markdown transport is online.\n\n" + "`callback keyboard preserved`" + ) + + +def rich_menu_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="Balance", callback_data="menu:balance"), + InlineKeyboardButton(text="Buy", callback_data="menu:buy"), + ], + [InlineKeyboardButton(text="Info", callback_data="menu:info")], + ] + ) + + +def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool: + message = str(exc).lower() + return "webpage_" in message or "media_empty" in message or "media_invalid" in message + + +async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]: + """Exercise Bedolaga's send, no-logo retry, keyboard and rich edit path.""" + markup = rich_menu_keyboard() + try: + html_message = await bot.send_rich_message( + chat_id=chat_id, + rich_message=InputRichMessage( + html=rich_menu_html(marker, include_logo=True), + skip_entity_detection=True, + ), + reply_markup=markup, + ) + except TelegramBadRequest as exc: + if not is_rich_media_retry_error(exc): + raise + LOG.info("rich logo fetch rejected; retrying the menu without logo") + html_message = await bot.send_rich_message( + chat_id=chat_id, + rich_message=InputRichMessage( + html=rich_menu_html(marker, include_logo=False), + skip_entity_detection=True, + ), + reply_markup=markup, + ) + + markdown_message = await bot.send_rich_message( + chat_id=chat_id, + rich_message=InputRichMessage( + markdown=rich_menu_markdown(marker), + skip_entity_detection=True, + ), + reply_markup=markup, + ) + await bot.edit_message_text( + chat_id=chat_id, + message_id=html_message.message_id, + rich_message=InputRichMessage( + html=rich_menu_html(f"{marker} EDITED", include_logo=False), + skip_entity_detection=True, + ), + reply_markup=markup, + ) + ids = [html_message.message_id, markdown_message.message_id] + LOG.info("sent rich menu suite chat_id=%s message_ids=%s", chat_id, ids) + return ids + + def build_dispatcher(marker: str) -> Dispatcher: router = Router(name="telesrv-bedolaga-format") @@ -173,6 +281,16 @@ def build_dispatcher(marker: str) -> Dispatcher: ids, ) + @router.message(Command("richdemo")) + async def rich_demo(message: Message) -> None: + ids = await send_rich_suite(message.bot, message.chat.id, marker) + LOG.info( + "handled /richdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s", + message.chat.id, + message.message_id, + ids, + ) + dispatcher = Dispatcher() dispatcher.include_router(router) return dispatcher @@ -190,13 +308,16 @@ async def run(args: argparse.Namespace) -> None: args.marker, ) if args.send_chat_id is not None: - await send_format_suite(bot, args.send_chat_id, args.marker) + if not args.rich_only: + await send_format_suite(bot, args.send_chat_id, args.marker) + if args.rich_menu or args.rich_only: + await send_rich_suite(bot, args.send_chat_id, args.marker) if args.send_only: return await bot.delete_webhook(drop_pending_updates=args.drop_pending) dispatcher = build_dispatcher(args.marker) - LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id) + LOG.info("polling started; send /start, /formatdemo or /richdemo to @%s", me.username or me.id) await dispatcher.start_polling( bot, allowed_updates=["message"], diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py index 26530af2..d4e005e2 100644 --- a/cmd/bots/bedolagaformat/test_demo.py +++ b/cmd/bots/bedolagaformat/test_demo.py @@ -5,6 +5,9 @@ import unittest from unittest.mock import AsyncMock from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest +from aiogram.methods import SendRichMessage +from aiogram.types import InputRichMessage MODULE_PATH = Path(__file__).with_name("demo.py") @@ -47,6 +50,45 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN) self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2) + def test_rich_menu_covers_bedolaga_html_and_keyboard(self) -> None: + html = demo.rich_menu_html("BEDOLAGA123", include_logo=False) + self.assertIn("

BEDOLAGA123 Admin

", html) + self.assertIn("", html) + self.assertIn("", html) + self.assertIn("
Statusnow
+
More

healthy

+ `, + }) + if err != nil { + t.Fatalf("parse Bedolaga rich HTML: %v", err) + } + decoded, err := tgRichMessage(rich) + if err != nil { + t.Fatalf("decode rich HTML: %v", err) + } + if !decoded.Rtl { + t.Fatal("rich HTML lost is_rtl") + } + var heading, table, details, footer bool + for _, block := range decoded.Blocks { + switch value := block.(type) { + case *tg.PageBlockHeading4: + heading = true + case *tg.PageBlockTable: + table = true + if !value.Bordered || !value.Striped || len(value.Rows) != 1 || len(value.Rows[0].Cells) != 2 { + t.Fatalf("table shape = %+v", value) + } + cell := value.Rows[0].Cells[1] + if !cell.AlignRight || !cell.ValignBottom || !richTextContainsDate(cell.Text, 1700000000) { + t.Fatalf("table date/alignment = %+v", cell) + } + case *tg.PageBlockDetails: + details = value.Open && len(value.Blocks) != 0 + case *tg.PageBlockFooter: + footer = true + } + } + if !heading || !table || !details || !footer { + t.Fatalf("parsed blocks heading=%v table=%v details=%v footer=%v: %#v", heading, table, details, footer, decoded.Blocks) + } + var projected struct { + RTL bool `json:"is_rtl"` + Blocks []struct { + Type string `json:"type"` + } `json:"blocks"` + } + if err := json.Unmarshal(rich.BotAPIProjection, &projected); err != nil { + t.Fatalf("decode Bot API projection: %v", err) + } + if !projected.RTL || len(projected.Blocks) != len(decoded.Blocks) { + t.Fatalf("Bot API projection = %s", rich.BotAPIProjection) + } + + if _, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageHTML{ + HTML: `

Admin

`, + }); err == nil || !tgerr.Is(err, "WEBPAGE_MEDIA_EMPTY") { + t.Fatalf("HTML media err = %v, want WEBPAGE_MEDIA_EMPTY for Bedolaga no-logo retry", err) + } +} + +func TestBotAPIRichMarkdownParsesAndProjects(t *testing.T) { + r := &Router{} + rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessageMarkdown{ + Markdown: "# Bedolaga\n\n**Subscription:** active", + }) + if err != nil { + t.Fatalf("parse rich Markdown: %v", err) + } + decoded, err := tgRichMessage(rich) + if err != nil { + t.Fatalf("decode rich Markdown: %v", err) + } + if len(decoded.Blocks) < 2 || len(rich.BotAPIProjection) == 0 || !strings.Contains(string(rich.BotAPIProjection), "Bedolaga") { + t.Fatalf("Markdown decoded=%#v projection=%s", decoded.Blocks, rich.BotAPIProjection) + } +} + +func richTextContainsDate(text tg.RichTextClass, want int) bool { + switch value := text.(type) { + case *tg.TextDate: + return value.Date == want + case *tg.TextConcat: + for _, child := range value.Texts { + if richTextContainsDate(child, want) { + return true + } + } + case *tg.TextBold: + return richTextContainsDate(value.Text, want) + case *tg.TextItalic: + return richTextContainsDate(value.Text, want) + case *tg.TextFixed: + return richTextContainsDate(value.Text, want) + case *tg.TextURL: + return richTextContainsDate(value.Text, want) + } + return false +} + func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) { ctx := context.Background() r := &Router{} @@ -417,7 +522,7 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Message: "rich", + Message: "", RandomID: 7001, RichMessage: &tg.InputRichMessage{ Rtl: true, @@ -642,7 +747,7 @@ func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) { updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Message: "rich", + Message: "", RandomID: 7002, RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()}, }) @@ -681,7 +786,7 @@ func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) { updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Message: "rich+photo", + Message: "", RandomID: 7003, RichMessage: &tg.InputRichMessage{ Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}}, diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index 5906cff8..be2e03cc 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -184,8 +184,8 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend return nil, sendErr } } - // rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 - // Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。 + // rich_message(Layer 228 富文本):blocks、HTML、Markdown 均在边界归一为 + // PageBlock + 内嵌媒体快照;普通消息恒 nil。 var richMessage *domain.MessageRichMessage if req.RichMessage != nil { richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage) @@ -194,6 +194,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend return nil, sendErr } } + if req.Message != "" && richMessage != nil { + sendErr = mediaInvalidErr() + return nil, sendErr + } if req.Message == "" && richMessage == nil { sendErr = messageEmptyErr() return nil, sendErr diff --git a/internal/rpc/rich_message_limits.go b/internal/rpc/rich_message_limits.go new file mode 100644 index 00000000..02201ac9 --- /dev/null +++ b/internal/rpc/rich_message_limits.go @@ -0,0 +1,215 @@ +package rpc + +import ( + "unicode/utf8" + + "github.com/iamxvbaba/td/tg" +) + +const ( + richMessageLengthLimit = 32768 + richMessageMaxBlocks = 500 + richMessageMaxDepth = 16 + richMessageMaxMedia = 50 + richMessageMaxTableCols = 20 +) + +type richMessageMetrics struct { + textLength int + blocks int + depth int + media int + tableCols int +} + +func validateRichMessageBlocks(blocks []tg.PageBlockClass) error { + metrics := richMessageMetrics{} + collectRichMessageBlockMetrics(blocks, 1, &metrics) + if metrics.textLength > richMessageLengthLimit || metrics.blocks > richMessageMaxBlocks || + metrics.depth > richMessageMaxDepth || metrics.media > richMessageMaxMedia || metrics.tableCols > richMessageMaxTableCols { + return richMessageTooLongErr() + } + if metrics.blocks == 0 || metrics.textLength == 0 && metrics.media == 0 { + return richMessageInvalidErr() + } + return nil +} + +func collectRichMessageBlockMetrics(blocks []tg.PageBlockClass, depth int, metrics *richMessageMetrics) { + if depth > metrics.depth { + metrics.depth = depth + } + for _, block := range blocks { + metrics.blocks++ + switch value := block.(type) { + case *tg.PageBlockTitle: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockSubtitle: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeader: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockSubheader: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockKicker: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockParagraph: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockPreformatted: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockFooter: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading1: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading2: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading3: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading4: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading5: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockHeading6: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockMath: + metrics.textLength += utf16StringLength(value.Source) + case *tg.PageBlockThinking: + metrics.textLength += richTextUTF16Length(value.Text) + case *tg.PageBlockAuthorDate: + metrics.textLength += richTextUTF16Length(value.Author) + case *tg.PageBlockBlockquote: + metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption) + case *tg.PageBlockPullquote: + metrics.textLength += richTextUTF16Length(value.Text) + richTextUTF16Length(value.Caption) + case *tg.PageBlockBlockquoteBlocks: + metrics.textLength += richTextUTF16Length(value.Caption) + collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics) + case *tg.PageBlockDetails: + metrics.textLength += richTextUTF16Length(value.Title) + collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics) + case *tg.PageBlockList: + for _, item := range value.Items { + switch item := item.(type) { + case *tg.PageListItemText: + metrics.textLength += richTextUTF16Length(item.Text) + case *tg.PageListItemBlocks: + collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics) + } + } + case *tg.PageBlockOrderedList: + for _, item := range value.Items { + switch item := item.(type) { + case *tg.PageListOrderedItemText: + metrics.textLength += richTextUTF16Length(item.Text) + case *tg.PageListOrderedItemBlocks: + collectRichMessageBlockMetrics(item.Blocks, depth+1, metrics) + } + } + case *tg.PageBlockTable: + metrics.textLength += richTextUTF16Length(value.Title) + for _, row := range value.Rows { + columns := 0 + for _, cell := range row.Cells { + metrics.textLength += richTextUTF16Length(cell.Text) + if cell.Colspan > 1 { + columns += cell.Colspan + } else { + columns++ + } + } + if columns > metrics.tableCols { + metrics.tableCols = columns + } + } + case *tg.PageBlockCollage: + metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit) + collectRichMessageBlockMetrics(value.Items, depth+1, metrics) + case *tg.PageBlockSlideshow: + metrics.textLength += richTextUTF16Length(value.Caption.Text) + richTextUTF16Length(value.Caption.Credit) + collectRichMessageBlockMetrics(value.Items, depth+1, metrics) + case *tg.PageBlockCover: + collectRichMessageBlockMetrics([]tg.PageBlockClass{value.Cover}, depth+1, metrics) + case *tg.PageBlockEmbedPost: + collectRichMessageBlockMetrics(value.Blocks, depth+1, metrics) + case *tg.PageBlockPhoto, *tg.PageBlockVideo, *tg.PageBlockAudio: + metrics.media++ + } + } +} + +func richTextUTF16Length(text tg.RichTextClass) int { + switch value := text.(type) { + case nil, *tg.TextEmpty: + return 0 + case *tg.TextPlain: + return utf16StringLength(value.Text) + case *tg.TextConcat: + total := 0 + for _, child := range value.Texts { + total += richTextUTF16Length(child) + } + return total + case *tg.TextBold: + return richTextUTF16Length(value.Text) + case *tg.TextItalic: + return richTextUTF16Length(value.Text) + case *tg.TextUnderline: + return richTextUTF16Length(value.Text) + case *tg.TextStrike: + return richTextUTF16Length(value.Text) + case *tg.TextFixed: + return richTextUTF16Length(value.Text) + case *tg.TextSubscript: + return richTextUTF16Length(value.Text) + case *tg.TextSuperscript: + return richTextUTF16Length(value.Text) + case *tg.TextMarked: + return richTextUTF16Length(value.Text) + case *tg.TextSpoiler: + return richTextUTF16Length(value.Text) + case *tg.TextURL: + return richTextUTF16Length(value.Text) + case *tg.TextMention: + return richTextUTF16Length(value.Text) + case *tg.TextHashtag: + return richTextUTF16Length(value.Text) + case *tg.TextBotCommand: + return richTextUTF16Length(value.Text) + case *tg.TextCashtag: + return richTextUTF16Length(value.Text) + case *tg.TextAutoURL: + return richTextUTF16Length(value.Text) + case *tg.TextAutoEmail: + return richTextUTF16Length(value.Text) + case *tg.TextAutoPhone: + return richTextUTF16Length(value.Text) + case *tg.TextBankCard: + return richTextUTF16Length(value.Text) + case *tg.TextEmail: + return richTextUTF16Length(value.Text) + case *tg.TextPhone: + return richTextUTF16Length(value.Text) + case *tg.TextAnchor: + return richTextUTF16Length(value.Text) + case *tg.TextMentionName: + return richTextUTF16Length(value.Text) + case *tg.TextDate: + return richTextUTF16Length(value.Text) + case *tg.TextCustomEmoji: + return utf16StringLength(value.Alt) + case *tg.TextMath: + return utf16StringLength(value.Source) + default: + return 0 + } +} + +func utf16StringLength(value string) int { + length := 0 + for _, r := range value { + length++ + if r > utf8.RuneSelf && r > 0xffff { + length++ + } + } + return length +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index f930af2e..ab5fd796 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -156,7 +156,7 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { return &clone } -// cloneRichMessage 深拷 Layer 227 富文本快照:复制不透明 blocks 字节与内嵌媒体切片, +// cloneRichMessage 深拷 Layer 228 富文本快照:复制不透明 blocks、Bot API 投影与内嵌媒体切片, // 避免发送方/接收方两行共享底层切片(与 postgres 每盒独立 decode 对齐)。 func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage { if m == nil { @@ -166,6 +166,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage { clone.Blocks = append([]byte(nil), m.Blocks...) clone.Photos = append([]domain.Photo(nil), m.Photos...) clone.Documents = append([]domain.Document(nil), m.Documents...) + clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...) return &clone }