diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe51bf..0576044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v1.1.0 + +### Bot API 10.1 +- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers with forward-compatible handling of unknown types. +- Added rich message sending support: `tgapi.InputRichMessage`, `tgapi.SendRichMessage` params, and `API.SendRichMessage`/`API.SendRichMessageWithContext`. +- Added rich message draft streaming: `API.SendRichMessageDraft`/`API.SendRichMessageDraftWithContext` for ephemeral ~30-second previews of partially generated messages. +- Added rich message editing: `EditMessageText.RichMessage` (`InputRichMessage`); `Text` is now omitted from the request when empty so rich-only edits are valid. +- Added `tgapi.InputRichMessageContent` for rich content in inline query results. +- Added join request query support: `User.SupportsJoinRequestQueries`, `ChatFullInfo.GuardBot`, `ChatJoinRequest.QueryID`, `API.AnswerChatJoinRequestQuery` with `ChatJoinRequestQueryResult` constants (`JoinRequestApprove`/`JoinRequestDecline`/`JoinRequestQueue`), and `API.SendChatJoinRequestWebApp` (plus `WithContext` variants). +- Added poll link media: the `tgapi.Link` type, `PollMedia.Link`, and the "link" type with `URL` on `InputPollOptionMedia`. + +### Added +- Added the `tgfmt` rich HTML DSL: typed `Rich` (inline) and `RichBlock` (block) fragments whose constructor signatures make invalid nesting uncompilable, inline helpers (`NewRich`, `Bold`, `Link`, `Mention`, `Emoji`, `Time`, `Math`, ...), block constructors (`H1`–`H6`, `P`, `Pre`/`PreCode`, `Footer`, `Hr`, `Ul`/`Ol`/`Li`/`LiCheckbox`, `Blockquote`/`Aside`, `Photo`/`Video`/`Audio` media with captions and spoilers, `Map`, `Collage`/`Slideshow`, `Table`/`Row`/`Cell`, `Details`, `MathBlock`, anchors), and the top-level `RichItem`/`RichHTML`/`RichMessage` assembly into `tgapi.InputRichMessage` (with `skip_entity_detection` enabled by default). +- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for sending rich messages built from `tgfmt` fragments. + ## v1.0.2 ### Fixed diff --git a/msg_context.go b/msg_context.go index e95dc2b..e3c649b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -11,6 +11,7 @@ import ( "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgfmt" "git.scuroneko.dev/scuroneko/sneklog/v2" ) @@ -821,3 +822,44 @@ func (ctx *MessageContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2) } + +func (ctx *MessageContext) richAnswer(rich tgapi.InputRichMessage, keyboard *InlineKeyboard) *AnswerMessage { + if ctx.Msg == nil { + ctx.Logger.Errorln(ErrMessageContextNil) + return nil + } + params := tgapi.SendRichMessage{ + ChatID: ctx.Msg.Chat.ID, + RichMessage: rich, + } + if keyboard != nil { + params.ReplyMarkup = keyboard.Get() + } + if ctx.Msg.MessageThreadID > 0 { + params.MessageThreadID = int64(ctx.Msg.MessageThreadID) + } + if ctx.Msg.DirectMessageTopic != nil { + params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID + } + + msg, err := ctx.API.SendRichMessageWithContext(ctx.Context(), params) + if err != nil { + ctx.Logger.Errorln(err) + return nil + } + return &AnswerMessage{ + MessageID: msg.MessageID, ctx: ctx, Text: rich.HTML, IsMedia: false, + } +} + +// RichAnswer sends a rich message (Bot API 10.1) built from tgfmt fragments. +// Both inline (tgfmt.Rich) and block (tgfmt.RichBlock) fragments are accepted +// at the top level: Telegram merges adjacent inline content into paragraphs. +func (ctx *MessageContext) RichAnswer(items ...tgfmt.RichItem) *AnswerMessage { + return ctx.richAnswer(tgfmt.RichMessage(items...), nil) +} + +// RichAnswerKeyboard sends a rich message with an inline keyboard. +func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, items ...tgfmt.RichItem) *AnswerMessage { + return ctx.richAnswer(tgfmt.RichMessage(items...), keyboard) +} diff --git a/tgapi/api101_test.go b/tgapi/api101_test.go new file mode 100644 index 0000000..d21549c --- /dev/null +++ b/tgapi/api101_test.go @@ -0,0 +1,115 @@ +package tgapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestEditMessageTextMarshalsInputRichMessage(t *testing.T) { + params := EditMessageText{ + ChatID: 1, + MessageID: 2, + RichMessage: &InputRichMessage{ + HTML: "
hi
", + SkipEntityDetection: true, + }, + } + data, err := json.Marshal(params) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + got := string(data) + for _, want := range []string{`"rich_message":{"html":`, `"skip_entity_detection":true`} { + if !strings.Contains(got, want) { + t.Fatalf("missing %s in editMessageText JSON: %s", want, got) + } + } + if strings.Contains(got, `"blocks"`) { + t.Fatalf("rich_message must be an InputRichMessage, not a block tree: %s", got) + } + if strings.Contains(got, `"text"`) { + t.Fatalf("empty text must be omitted when editing rich content: %s", got) + } +} + +func TestSendRichMessageDraftMarshal(t *testing.T) { + params := SendRichMessageDraft{ + ChatID: 1, + DraftID: 7, + RichMessage: InputRichMessage{Markdown: "*hi*"}, + } + data, err := json.Marshal(params) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + got := string(data) + for _, want := range []string{`"chat_id":1`, `"draft_id":7`, `"rich_message":{"markdown":"*hi*"}`} { + if !strings.Contains(got, want) { + t.Fatalf("missing %s in sendRichMessageDraft JSON: %s", want, got) + } + } +} + +func TestInputRichMessageContentMarshal(t *testing.T) { + content := InputRichMessageContent{ + RichMessage: InputRichMessage{HTML: "hi
"}, + } + data, err := json.Marshal(content) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if got := string(data); !strings.Contains(got, `"rich_message":{"html":`) { + t.Fatalf("unexpected InputRichMessageContent JSON: %s", got) + } +} + +func TestInputPollOptionMediaLinkMarshal(t *testing.T) { + media := InputPollOptionMedia{Type: "link", URL: "https://example.com"} + data, err := json.Marshal(media) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + got := string(data) + if got != `{"type":"link","url":"https://example.com"}` { + t.Fatalf("unexpected link media JSON: %s", got) + } +} + +func TestPollMediaUnmarshalLink(t *testing.T) { + var media PollMedia + if err := json.Unmarshal([]byte(`{"link":{"url":"https://example.com"}}`), &media); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if media.Link == nil || media.Link.URL != "https://example.com" { + t.Fatalf("unexpected poll media link: %+v", media.Link) + } +} + +func TestChatJoinRequestUnmarshalQueryID(t *testing.T) { + payload := `{"chat":{"id":1},"from":{"id":2,"first_name":"A"},"user_chat_id":2,"date":3,"query_id":"q42"}` + var req ChatJoinRequest + if err := json.Unmarshal([]byte(payload), &req); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if req.QueryID == nil || *req.QueryID != "q42" { + t.Fatalf("unexpected query_id: %+v", req.QueryID) + } +} + +func TestAnswerChatJoinRequestQueryResultValues(t *testing.T) { + if JoinRequestApprove != "approve" || JoinRequestDecline != "decline" || JoinRequestQueue != "queue" { + t.Fatalf("unexpected join request query result values: %q %q %q", + JoinRequestApprove, JoinRequestDecline, JoinRequestQueue) + } +} + +func TestUserUnmarshalSupportsJoinRequestQueries(t *testing.T) { + var user User + if err := json.Unmarshal([]byte(`{"id":1,"first_name":"A","supports_join_request_queries":true}`), &user); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if user.SupportsJoinRequestQueries == nil || !*user.SupportsJoinRequestQueries { + t.Fatalf("unexpected supports_join_request_queries: %+v", user.SupportsJoinRequestQueries) + } +} diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index b1a6c45..b6ac278 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -166,11 +166,13 @@ type PollOption struct { } // InputPollOptionMedia describes the media to attach to a poll option. +// For type "link" set URL instead of Media. // Since: Bot API 10.0 // See https://core.telegram.org/bots/api#inputpolloptionmedia type InputPollOptionMedia struct { Type string `json:"type"` - Media string `json:"media"` + Media string `json:"media,omitempty"` + URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link" } // InputPollOption contains information about one answer option in a poll to be sent. @@ -258,12 +260,20 @@ type Poll struct { Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0 } +// Link represents an HTTP link. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#link +type Link struct { + URL string `json:"url"` +} + // PollMedia represents media attached to a poll. // Since: Bot API 10.0 type PollMedia struct { Animation *Animation `json:"animation,omitempty"` Audio *Audio `json:"audio,omitempty"` Document *Document `json:"document,omitempty"` + Link *Link `json:"link,omitempty"` // Since: Bot API 10.1 LivePhoto *LivePhoto `json:"live_photo,omitempty"` Location *Location `json:"location,omitempty"` Photo []PhotoSize `json:"photo,omitempty"` diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index d718914..37d6349 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -481,6 +481,73 @@ func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params De return req.DoWithContext(ctx, api) } +// ChatJoinRequestQueryResult is the verdict passed to answerChatJoinRequestQuery. +// Since: Bot API 10.1 +type ChatJoinRequestQueryResult string + +const ( + // JoinRequestApprove allows the user to join the chat. + JoinRequestApprove ChatJoinRequestQueryResult = "approve" + // JoinRequestDecline disallows the user to join the chat. + JoinRequestDecline ChatJoinRequestQueryResult = "decline" + // JoinRequestQueue leaves the decision to other administrators. + JoinRequestQueue ChatJoinRequestQueryResult = "queue" +) + +// AnswerChatJoinRequestQuery holds parameters for the answerChatJoinRequestQuery method. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#answerchatjoinrequestquery +type AnswerChatJoinRequestQuery struct { + ChatJoinRequestQueryID string `json:"chat_join_request_query_id"` + Result ChatJoinRequestQueryResult `json:"result"` +} + +// AnswerChatJoinRequestQuery processes a received chat join request query. +// Since: Bot API 10.1 +// Returns True on success. +// See https://core.telegram.org/bots/api#answerchatjoinrequestquery +func (api *API) AnswerChatJoinRequestQuery(params AnswerChatJoinRequestQuery) (bool, error) { + req := NewRequest[bool]("answerChatJoinRequestQuery", params) + return req.Do(api) +} + +// AnswerChatJoinRequestQueryWithContext is the context-aware variant of AnswerChatJoinRequestQuery. +// Since: Bot API 10.1 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answerchatjoinrequestquery +func (api *API) AnswerChatJoinRequestQueryWithContext(ctx context.Context, params AnswerChatJoinRequestQuery) (bool, error) { + req := NewRequest[bool]("answerChatJoinRequestQuery", params) + return req.DoWithContext(ctx, api) +} + +// SendChatJoinRequestWebApp holds parameters for the sendChatJoinRequestWebApp method. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp +type SendChatJoinRequestWebApp struct { + ChatJoinRequestQueryID string `json:"chat_join_request_query_id"` + WebAppURL string `json:"web_app_url"` +} + +// SendChatJoinRequestWebApp shows a Mini App to the user before deciding a +// join request query; resolve the query with AnswerChatJoinRequestQuery based +// on the Mini App interaction. +// Since: Bot API 10.1 +// Returns True on success. +// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp +func (api *API) SendChatJoinRequestWebApp(params SendChatJoinRequestWebApp) (bool, error) { + req := NewRequest[bool]("sendChatJoinRequestWebApp", params) + return req.Do(api) +} + +// SendChatJoinRequestWebAppWithContext is the context-aware variant of SendChatJoinRequestWebApp. +// Since: Bot API 10.1 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp +func (api *API) SendChatJoinRequestWebAppWithContext(ctx context.Context, params SendChatJoinRequestWebApp) (bool, error) { + req := NewRequest[bool]("sendChatJoinRequestWebApp", params) + return req.DoWithContext(ctx, api) +} + // SetChatPhoto holds parameters for the setChatPhoto method. // Since: Bot API 3.1 // See https://core.telegram.org/bots/api#setchatphoto diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index b929a21..8ff5522 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -52,6 +52,7 @@ type ChatFullInfo struct { PersonalChat *Chat `json:"personal_chat,omitempty"` ParentChat *Chat `json:"parent_chat,omitempty"` // Since: Bot API 9.2 + GuardBot *User `json:"guard_bot,omitempty"` // Since: Bot API 10.1; visible to chat administrators only AvailableReaction []ReactionType `json:"available_reaction,omitempty"` diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go index bfc5c27..032d359 100644 --- a/tgapi/inline_types.go +++ b/tgapi/inline_types.go @@ -14,6 +14,15 @@ type InlineQueryResultsButton struct { StartParameter string `json:"start_parameter,omitempty"` } +// InputRichMessageContent represents the content of a rich message to be +// sent as the result of an inline query. Use it as the input_message_content +// value of an InlineQueryResult. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#inputrichmessagecontent +type InputRichMessageContent struct { + RichMessage InputRichMessage `json:"rich_message"` +} + // SentWebAppMessage describes an inline message sent by a Web App on behalf of a user. // Since: Bot API 8.0 // See https://core.telegram.org/bots/api#sentwebappmessage diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index a77d190..77ba304 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -547,10 +547,11 @@ type EditMessageText struct { ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` - Text string `json:"text"` + Text string `json:"text,omitempty"` // required unless RichMessage is set ParseMode ParseMode `json:"parse_mode,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + RichMessage *InputRichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1; required if Text is not specified ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } @@ -1089,3 +1090,71 @@ func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params Del req := NewRequest[bool]("deleteMessageReaction", params) return req.DoWithContext(ctx, api) } + +// SendRichMessage holds parameters for the sendRichMessage method. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#sendrichmessage +type SendRichMessage struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int64 `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"` + + RichMessage InputRichMessage `json:"rich_message"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` +} + +// SendRichMessage sends a rich formatted message. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#sendrichmessage +func (api *API) SendRichMessage(params SendRichMessage) (Message, error) { + req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID) + return req.Do(api) +} + +// SendRichMessageWithContext is the context-aware variant of SendRichMessage. +// Since: Bot API 10.1 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendrichmessage +func (api *API) SendRichMessageWithContext(ctx context.Context, params SendRichMessage) (Message, error) { + req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + +// SendRichMessageDraft holds parameters for the sendRichMessageDraft method. +// Since: Bot API 10.1 +// See https://core.telegram.org/bots/api#sendrichmessagedraft +type SendRichMessageDraft struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int64 `json:"message_thread_id,omitempty"` + + // DraftID must be non-zero; changes to drafts with the same identifier are animated. + DraftID int64 `json:"draft_id"` + RichMessage InputRichMessage `json:"rich_message"` +} + +// SendRichMessageDraft streams a partial rich message to a private chat while +// the message is being generated. The draft is an ephemeral ~30-second +// preview; call SendRichMessage with the complete message to persist it. +// Since: Bot API 10.1 +// Returns True on success. +// See https://core.telegram.org/bots/api#sendrichmessagedraft +func (api *API) SendRichMessageDraft(params SendRichMessageDraft) (bool, error) { + req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID) + return req.Do(api) +} + +// SendRichMessageDraftWithContext is the context-aware variant of SendRichMessageDraft. +// Since: Bot API 10.1 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendrichmessagedraft +func (api *API) SendRichMessageDraftWithContext(ctx context.Context, params SendRichMessageDraft) (bool, error) { + req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 95c1522..4e4e895 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -151,7 +151,8 @@ type Message struct { SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1 EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4 - Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0 + RichMessage *RichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1 + Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0 Audio *Audio `json:"audio,omitempty"` Document *Document `json:"document,omitempty"` PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6 @@ -748,3 +749,19 @@ type VideoChatParticipantsInvited struct { type SentGuestMessage struct { InlineMessageID string `json:"inline_message_id"` } + +// RichMessage Rich formatted message. +// Since: Bot API 10.1 +type RichMessage struct { + Blocks []RichBlock `json:"blocks"` + IsRTL bool `json:"is_rtl,omitempty"` +} + +// InputRichMessage Describes a rich message to be sent. Exactly one of the fields html or markdown must be used. +// Since: Bot API 10.1 +type InputRichMessage struct { + HTML string `json:"html,omitempty"` + Markdown string `json:"markdown,omitempty"` + IsRTL bool `json:"is_rtl,omitempty"` + SkipEntityDetection bool `json:"skip_entity_detection,omitempty"` +} diff --git a/tgapi/richblock.go b/tgapi/richblock.go new file mode 100644 index 0000000..a88f9db --- /dev/null +++ b/tgapi/richblock.go @@ -0,0 +1,738 @@ +package tgapi + +import ( + "encoding/json" + "fmt" +) + +// RichBlock is a block in a structured rich message. +type RichBlock interface { + isRichBlock() +} + +// --------------------------------------------------------------------------- +// Helper types +// --------------------------------------------------------------------------- + +// RichBlockCaption is the caption of a media block or container. +type RichBlockCaption struct { + Text RichText + Credit RichText +} + +func (c RichBlockCaption) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Text RichText `json:"text"` + Credit RichText `json:"credit,omitempty"` + }{c.Text, c.Credit}) +} + +func (c *RichBlockCaption) UnmarshalJSON(data []byte) error { + var raw struct { + Text json.RawMessage `json:"text"` + Credit json.RawMessage `json:"credit"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + text, err := parseOptRichText(raw.Text) + if err != nil { + return err + } + credit, err := parseOptRichText(raw.Credit) + if err != nil { + return err + } + *c = RichBlockCaption{text, credit} + return nil +} + +// RichBlockListItem is a single list item. Label is the ready-to-display +// visible marker ("1.", "c.", "vii.", "•"): the server renders it itself +// when parsing html/markdown. +type RichBlockListItem struct { + Label string + Blocks []RichBlock + HasCheckbox bool + IsChecked bool + Value int // for ordered lists: numeric value of the marker + Type string // for ordered lists: "a", "A", "i", "I" or "1" +} + +func (i RichBlockListItem) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Label string `json:"label"` + Blocks []RichBlock `json:"blocks"` + HasCheckbox bool `json:"has_checkbox,omitempty"` + IsChecked bool `json:"is_checked,omitempty"` + Value int `json:"value,omitempty"` + Type string `json:"type,omitempty"` + }{i.Label, i.Blocks, i.HasCheckbox, i.IsChecked, i.Value, i.Type}) +} + +func (i *RichBlockListItem) UnmarshalJSON(data []byte) error { + var raw struct { + Label string `json:"label"` + Blocks json.RawMessage `json:"blocks"` + HasCheckbox bool `json:"has_checkbox"` + IsChecked bool `json:"is_checked"` + Value int `json:"value"` + Type string `json:"type"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return err + } + *i = RichBlockListItem{raw.Label, blocks, raw.HasCheckbox, raw.IsChecked, raw.Value, raw.Type} + return nil +} + +// RichBlockTableCell is a table cell. An empty Text means an invisible cell. +type RichBlockTableCell struct { + Text RichText + IsHeader bool + Colspan int + Rowspan int + Align string // "left", "center" or "right" + VAlign string // "top", "middle" or "bottom" +} + +func (c RichBlockTableCell) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Text RichText `json:"text,omitempty"` + IsHeader bool `json:"is_header,omitempty"` + Colspan int `json:"colspan,omitempty"` + Rowspan int `json:"rowspan,omitempty"` + Align string `json:"align,omitempty"` + VAlign string `json:"valign,omitempty"` + }{c.Text, c.IsHeader, c.Colspan, c.Rowspan, c.Align, c.VAlign}) +} + +func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error { + var raw struct { + Text json.RawMessage `json:"text"` + IsHeader bool `json:"is_header"` + Colspan int `json:"colspan"` + Rowspan int `json:"rowspan"` + Align string `json:"align"` + VAlign string `json:"valign"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + text, err := parseOptRichText(raw.Text) + if err != nil { + return err + } + *c = RichBlockTableCell{text, raw.IsHeader, raw.Colspan, raw.Rowspan, raw.Align, raw.VAlign} + return nil +} + +// --------------------------------------------------------------------------- +// RichBlockWrap: pure text blocks — paragraph, footer, thinking. +// --------------------------------------------------------------------------- + +// RichBlockWrap covers all blocks that have only a text field. +type RichBlockWrap struct { + Tag string + Text RichText +} + +func (RichBlockWrap) isRichBlock() {} + +func (b RichBlockWrap) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Text RichText `json:"text"` + }{b.Tag, b.Text}) +} + +var richBlockWrapTags = map[string]bool{ + "paragraph": true, "footer": true, "thinking": true, +} + +// --------------------------------------------------------------------------- +// Section heading: text + size +// --------------------------------------------------------------------------- + +// RichBlockSectionHeading is a section heading block. +type RichBlockSectionHeading struct { + Text RichText + Size int // 1-6, 1 is the largest +} + +func (RichBlockSectionHeading) isRichBlock() {} +func (b RichBlockSectionHeading) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Text RichText `json:"text"` + Size int `json:"size"` + }{"heading", b.Text, b.Size}) +} + +// --------------------------------------------------------------------------- +// Block with text + language +// --------------------------------------------------------------------------- + +// RichBlockPreformatted is a preformatted code block. +type RichBlockPreformatted struct { + Text RichText + Language string +} + +func (RichBlockPreformatted) isRichBlock() {} +func (b RichBlockPreformatted) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Text RichText `json:"text"` + Language string `json:"language,omitempty"` + }{"pre", b.Text, b.Language}) +} + +// --------------------------------------------------------------------------- +// Quotations +// --------------------------------------------------------------------------- + +// RichBlockQuotation is a block quotation with block-level content +// (officially RichBlockBlockQuotation). +type RichBlockQuotation struct { + Blocks []RichBlock + Credit RichText +} + +func (RichBlockQuotation) isRichBlock() {} +func (b RichBlockQuotation) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Blocks []RichBlock `json:"blocks"` + Credit RichText `json:"credit,omitempty"` + }{"blockquote", b.Blocks, b.Credit}) +} + +// RichBlockPullQuotation is a pull quotation with inline content. +type RichBlockPullQuotation struct { + Text RichText + Credit RichText +} + +func (RichBlockPullQuotation) isRichBlock() {} +func (b RichBlockPullQuotation) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Text RichText `json:"text"` + Credit RichText `json:"credit,omitempty"` + }{"pullquote", b.Text, b.Credit}) +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +// RichBlockList is a list block. +type RichBlockList struct { + Items []RichBlockListItem +} + +func (RichBlockList) isRichBlock() {} +func (b RichBlockList) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Items []RichBlockListItem `json:"items"` + }{"list", b.Items}) +} + +// --------------------------------------------------------------------------- +// Containers with blocks []RichBlock + caption +// --------------------------------------------------------------------------- + +// RichBlockCollage is a collage of media blocks. +type RichBlockCollage struct { + Blocks []RichBlock + Caption *RichBlockCaption +} + +func (RichBlockCollage) isRichBlock() {} +func (b RichBlockCollage) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Blocks []RichBlock `json:"blocks"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"collage", b.Blocks, b.Caption}) +} + +// RichBlockSlideshow is a slideshow of media blocks. +type RichBlockSlideshow struct { + Blocks []RichBlock + Caption *RichBlockCaption +} + +func (RichBlockSlideshow) isRichBlock() {} +func (b RichBlockSlideshow) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Blocks []RichBlock `json:"blocks"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"slideshow", b.Blocks, b.Caption}) +} + +// --------------------------------------------------------------------------- +// Details — expandable block +// --------------------------------------------------------------------------- + +// RichBlockDetails is an expandable block with an inline summary. +type RichBlockDetails struct { + Summary RichText + Blocks []RichBlock + IsOpen bool +} + +func (RichBlockDetails) isRichBlock() {} +func (b RichBlockDetails) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Summary RichText `json:"summary"` + Blocks []RichBlock `json:"blocks"` + IsOpen bool `json:"is_open,omitempty"` + }{"details", b.Summary, b.Blocks, b.IsOpen}) +} + +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- + +// RichBlockTable is a table block. +type RichBlockTable struct { + Cells [][]RichBlockTableCell + IsBordered bool + IsStriped bool + Caption RichText +} + +func (RichBlockTable) isRichBlock() {} +func (b RichBlockTable) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Cells [][]RichBlockTableCell `json:"cells"` + IsBordered bool `json:"is_bordered,omitempty"` + IsStriped bool `json:"is_striped,omitempty"` + Caption RichText `json:"caption,omitempty"` + }{"table", b.Cells, b.IsBordered, b.IsStriped, b.Caption}) +} + +// --------------------------------------------------------------------------- +// Map +// --------------------------------------------------------------------------- + +// RichBlockMap is a location map block. +type RichBlockMap struct { + Location Location + Zoom int // 13-20 + Width int + Height int + Caption *RichBlockCaption +} + +func (RichBlockMap) isRichBlock() {} +func (b RichBlockMap) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Location Location `json:"location"` + Zoom int `json:"zoom"` + Width int `json:"width"` + Height int `json:"height"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"map", b.Location, b.Zoom, b.Width, b.Height, b.Caption}) +} + +// --------------------------------------------------------------------------- +// Media blocks +// --------------------------------------------------------------------------- + +// RichBlockPhoto is a photo block. +type RichBlockPhoto struct { + Photo []PhotoSize + HasSpoiler bool + Caption *RichBlockCaption +} + +func (RichBlockPhoto) isRichBlock() {} +func (b RichBlockPhoto) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Photo []PhotoSize `json:"photo"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"photo", b.Photo, b.HasSpoiler, b.Caption}) +} + +// RichBlockVideo is a video block. +type RichBlockVideo struct { + Video Video + HasSpoiler bool + Caption *RichBlockCaption +} + +func (RichBlockVideo) isRichBlock() {} +func (b RichBlockVideo) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Video Video `json:"video"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"video", b.Video, b.HasSpoiler, b.Caption}) +} + +// RichBlockAudio is an audio block. +type RichBlockAudio struct { + Audio Audio + Caption *RichBlockCaption +} + +func (RichBlockAudio) isRichBlock() {} +func (b RichBlockAudio) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Audio Audio `json:"audio"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"audio", b.Audio, b.Caption}) +} + +// RichBlockAnimation is an animation block. +type RichBlockAnimation struct { + Animation Animation + HasSpoiler bool + Caption *RichBlockCaption +} + +func (RichBlockAnimation) isRichBlock() {} +func (b RichBlockAnimation) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Animation Animation `json:"animation"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"animation", b.Animation, b.HasSpoiler, b.Caption}) +} + +// RichBlockVoiceNote is a voice note block. +type RichBlockVoiceNote struct { + VoiceNote Voice + Caption *RichBlockCaption +} + +func (RichBlockVoiceNote) isRichBlock() {} +func (b RichBlockVoiceNote) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + VoiceNote Voice `json:"voice_note"` + Caption *RichBlockCaption `json:"caption,omitempty"` + }{"voice_note", b.VoiceNote, b.Caption}) +} + +// --------------------------------------------------------------------------- +// Leaves without nested content +// --------------------------------------------------------------------------- + +// RichBlockDivider is a horizontal divider block. +type RichBlockDivider struct{} + +func (RichBlockDivider) isRichBlock() {} +func (b RichBlockDivider) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + }{"divider"}) +} + +// RichBlockMathematicalExpression is a block-level mathematical expression. +type RichBlockMathematicalExpression struct { + Expression string +} + +func (RichBlockMathematicalExpression) isRichBlock() {} +func (b RichBlockMathematicalExpression) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Expression string `json:"expression"` + }{"mathematical_expression", b.Expression}) +} + +// RichBlockAnchor is a named anchor block that anchor links can point to. +type RichBlockAnchor struct { + Name string +} + +func (RichBlockAnchor) isRichBlock() {} +func (b RichBlockAnchor) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + Name string `json:"name"` + }{"anchor", b.Name}) +} + +// --------------------------------------------------------------------------- +// JSON -> RichBlock parsing +// --------------------------------------------------------------------------- + +// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the +// type tag. Unknown types that carry a text field are preserved as +// RichBlockWrap for forward compatibility. +func UnmarshalRichBlock(data []byte) (RichBlock, error) { + var head struct { + Type string `json:"type"` + Text json.RawMessage `json:"text"` + } + if err := json.Unmarshal(data, &head); err != nil { + return nil, fmt.Errorf("richblock: %w", err) + } + + if richBlockWrapTags[head.Type] { + text, err := parseOptRichText(head.Text) + if err != nil { + return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err) + } + return RichBlockWrap{Tag: head.Type, Text: text}, nil + } + + switch head.Type { + case "heading": + var v struct { + Size int `json:"size"` + } + _ = json.Unmarshal(data, &v) + text, _ := parseOptRichText(head.Text) + return RichBlockSectionHeading{text, v.Size}, nil + + case "pre": + var v struct { + Language string `json:"language"` + } + _ = json.Unmarshal(data, &v) + text, _ := parseOptRichText(head.Text) + return RichBlockPreformatted{text, v.Language}, nil + + case "blockquote": + var raw struct { + Blocks json.RawMessage `json:"blocks"` + Credit json.RawMessage `json:"credit"` + } + _ = json.Unmarshal(data, &raw) + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return nil, err + } + credit, _ := parseOptRichText(raw.Credit) + return RichBlockQuotation{blocks, credit}, nil + + case "pullquote": + var raw struct { + Credit json.RawMessage `json:"credit"` + } + _ = json.Unmarshal(data, &raw) + text, _ := parseOptRichText(head.Text) + credit, _ := parseOptRichText(raw.Credit) + return RichBlockPullQuotation{text, credit}, nil + + case "list": + var v struct { + Items []RichBlockListItem `json:"items"` + } + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return RichBlockList{v.Items}, nil + + case "collage": + var raw struct { + Blocks json.RawMessage `json:"blocks"` + Caption *RichBlockCaption `json:"caption"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return nil, err + } + return RichBlockCollage{blocks, raw.Caption}, nil + + case "slideshow": + var raw struct { + Blocks json.RawMessage `json:"blocks"` + Caption *RichBlockCaption `json:"caption"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return nil, err + } + return RichBlockSlideshow{blocks, raw.Caption}, nil + + case "details": + var raw struct { + Summary json.RawMessage `json:"summary"` + Blocks json.RawMessage `json:"blocks"` + IsOpen bool `json:"is_open"` + } + _ = json.Unmarshal(data, &raw) + summary, _ := parseOptRichText(raw.Summary) + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return nil, err + } + return RichBlockDetails{summary, blocks, raw.IsOpen}, nil + + case "table": + var raw struct { + Cells [][]RichBlockTableCell `json:"cells"` + IsBordered bool `json:"is_bordered"` + IsStriped bool `json:"is_striped"` + Caption json.RawMessage `json:"caption"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + caption, _ := parseOptRichText(raw.Caption) + return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, caption}, nil + + case "map": + var v struct { + Location Location `json:"location"` + Zoom int `json:"zoom"` + Width int `json:"width"` + Height int `json:"height"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockMap{v.Location, v.Zoom, v.Width, v.Height, v.Caption}, nil + + case "photo": + var v struct { + Photo []PhotoSize `json:"photo"` + HasSpoiler bool `json:"has_spoiler"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockPhoto{v.Photo, v.HasSpoiler, v.Caption}, nil + + case "video": + var v struct { + Video Video `json:"video"` + HasSpoiler bool `json:"has_spoiler"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil + + case "audio": + var v struct { + Audio Audio `json:"audio"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockAudio{v.Audio, v.Caption}, nil + + case "animation": + var v struct { + Animation Animation `json:"animation"` + HasSpoiler bool `json:"has_spoiler"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil + + case "voice_note": + var v struct { + VoiceNote Voice `json:"voice_note"` + Caption *RichBlockCaption `json:"caption"` + } + _ = json.Unmarshal(data, &v) + return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil + + case "divider": + return RichBlockDivider{}, nil + + case "mathematical_expression": + var v struct { + Expression string `json:"expression"` + } + _ = json.Unmarshal(data, &v) + return RichBlockMathematicalExpression{v.Expression}, nil + + case "anchor": + var v struct { + Name string `json:"name"` + } + _ = json.Unmarshal(data, &v) + return RichBlockAnchor{v.Name}, nil + + default: + // forward-compat: unknown type with text -> RichBlockWrap, without text -> error. + if text, err := parseOptRichText(head.Text); err == nil && text != nil { + return RichBlockWrap{Tag: head.Type, Text: text}, nil + } + return nil, fmt.Errorf("richblock: unknown type %q", head.Type) + } +} + +// UnmarshalRichMessage parses a root RichMessage from JSON. +func UnmarshalRichMessage(data []byte) (RichMessage, error) { + var raw struct { + Blocks json.RawMessage `json:"blocks"` + IsRTL bool `json:"is_rtl"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return RichMessage{}, fmt.Errorf("richmessage: %w", err) + } + blocks, err := unmarshalRichBlocks(raw.Blocks) + if err != nil { + return RichMessage{}, err + } + return RichMessage{blocks, raw.IsRTL}, nil +} + +// UnmarshalJSON parses the blocks through UnmarshalRichBlock: the Blocks +// field is interface-typed, so the standard unmarshaler cannot handle it. +func (m *RichMessage) UnmarshalJSON(data []byte) error { + parsed, err := UnmarshalRichMessage(data) + if err != nil { + return err + } + *m = parsed + return nil +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// parseOptRichText parses an optional RichText field: absent and null yield nil. +func parseOptRichText(raw json.RawMessage) (RichText, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + return UnmarshalRichText(raw) +} + +func unmarshalRichBlocks(raw json.RawMessage) ([]RichBlock, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var raws []json.RawMessage + if err := json.Unmarshal(raw, &raws); err != nil { + return nil, err + } + blocks := make([]RichBlock, len(raws)) + for i, r := range raws { + b, err := UnmarshalRichBlock(r) + if err != nil { + return nil, err + } + blocks[i] = b + } + return blocks, nil +} diff --git a/tgapi/richblock_test.go b/tgapi/richblock_test.go new file mode 100644 index 0000000..8f8de2b --- /dev/null +++ b/tgapi/richblock_test.go @@ -0,0 +1,253 @@ +package tgapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func roundtripRichBlock(t *testing.T, in RichBlock) { + t.Helper() + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out, err := UnmarshalRichBlock(b) + if err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + b2, err := json.Marshal(out) + if err != nil { + t.Fatalf("remarshal: %v", err) + } + if string(b) != string(b2) { + t.Fatalf("not stable:\n %s\n %s", b, b2) + } +} + +func par(s string) RichBlockWrap { return RichBlockWrap{"paragraph", RichTextPlain(s)} } + +func TestRichBlockRoundtrip(t *testing.T) { + cases := []RichBlock{ + // wrap blocks + par("Hello, world"), + RichBlockWrap{"footer", RichTextPlain("© 2024")}, + RichBlockWrap{"thinking", RichTextPlain("Let me reason step by step.")}, + + // heading + RichBlockSectionHeading{RichTextWrap{"bold", RichTextPlain("Chapter 1")}, 1}, + RichBlockSectionHeading{RichTextPlain("smallest"), 6}, + + // preformatted + RichBlockPreformatted{RichTextPlain(`fmt.Println("hi")`), "go"}, + RichBlockPreformatted{Text: RichTextPlain("no language")}, + + // quotations + RichBlockQuotation{[]RichBlock{par("To be or not to be")}, RichTextPlain("Shakespeare")}, + RichBlockQuotation{Blocks: []RichBlock{par("anonymous"), par("second block")}}, + RichBlockPullQuotation{Text: RichTextPlain("Pull me")}, + RichBlockPullQuotation{RichTextPlain("Wisdom"), RichTextWrap{"italic", RichTextPlain("someone")}}, + + // list: label is the ready-made marker, numbering lives on the items + RichBlockList{ + Items: []RichBlockListItem{ + {Label: "c.", Blocks: []RichBlock{par("item 3")}, Value: 3, Type: "a"}, + {Label: "vii.", Blocks: []RichBlock{par("item 7")}, Value: 7, Type: "i"}, + }, + }, + RichBlockList{ + Items: []RichBlockListItem{ + {Label: "•", Blocks: []RichBlock{par("todo")}, HasCheckbox: true}, + {Label: "•", Blocks: []RichBlock{par("done")}, HasCheckbox: true, IsChecked: true}, + }, + }, + + // collage and slideshow + RichBlockCollage{ + Blocks: []RichBlock{RichBlockPhoto{Photo: []PhotoSize{{FileID: "abc123", Width: 100, Height: 100}}}}, + Caption: &RichBlockCaption{Text: RichTextPlain("A photo")}, + }, + RichBlockSlideshow{ + Blocks: []RichBlock{ + RichBlockVideo{Video: Video{FileID: "vid1", Width: 640, Height: 480, Duration: 10}}, + }, + }, + + // details + RichBlockDetails{ + Summary: RichTextPlain("Spoiler"), + Blocks: []RichBlock{par("Hidden content")}, + }, + RichBlockDetails{ + Summary: RichTextWrap{"bold", RichTextPlain("Open details")}, + Blocks: []RichBlock{RichBlockDivider{}, par("content")}, + IsOpen: true, + }, + + // table: text cells, headers, spans, alignment, invisible cell + RichBlockTable{ + Cells: [][]RichBlockTableCell{ + { + {Text: RichTextPlain("Name"), IsHeader: true, Align: "center"}, + {Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"}, + }, + { + {Text: RichTextPlain("Alice"), Colspan: 2}, + }, + { + {}, // invisible cell + {Text: RichTextPlain("42"), Rowspan: 2}, + }, + }, + IsBordered: true, + Caption: RichTextPlain("Results"), + }, + + // map + RichBlockMap{ + Location: Location{Latitude: 55.7558, Longitude: 37.6173}, + Zoom: 13, Width: 800, Height: 400, + Caption: &RichBlockCaption{Text: RichTextPlain("Moscow"), Credit: RichTextPlain("OpenStreetMap")}, + }, + + // media + RichBlockPhoto{ + Photo: []PhotoSize{{FileID: "p1", Width: 1280, Height: 720}}, + HasSpoiler: true, + Caption: &RichBlockCaption{Text: RichTextPlain("A cat"), Credit: RichTextWrap{"italic", RichTextPlain("photographer")}}, + }, + RichBlockVideo{Video: Video{FileID: "v1", Width: 1920, Height: 1080, Duration: 30}, HasSpoiler: true}, + RichBlockAudio{ + Audio: Audio{FileID: "a1", Duration: 60}, + Caption: &RichBlockCaption{Text: RichTextPlain("Podcast ep. 1")}, + }, + RichBlockAnimation{Animation: Animation{FileID: "g1", Width: 320, Height: 240, Duration: 2}}, + RichBlockVoiceNote{VoiceNote: Voice{FileID: "vn1", Duration: 5}}, + + // leaves + RichBlockDivider{}, + RichBlockMathematicalExpression{Expression: "E = mc^2"}, + RichBlockAnchor{Name: "section-2"}, + } + for _, c := range cases { + roundtripRichBlock(t, c) + } +} + +func TestRichMessageRoundtrip(t *testing.T) { + for _, msg := range []RichMessage{ + { + Blocks: []RichBlock{ + RichBlockSectionHeading{RichTextPlain("Title"), 1}, + RichBlockWrap{"paragraph", RichTextArray{RichTextPlain("Some "), RichTextWrap{"bold", RichTextPlain("bold")}, RichTextPlain(" text")}}, + RichBlockDivider{}, + RichBlockList{ + Items: []RichBlockListItem{ + {Label: "1.", Blocks: []RichBlock{par("First")}, Value: 1, Type: "1"}, + {Label: "2.", Blocks: []RichBlock{par("Second")}, Value: 2, Type: "1"}, + }, + }, + RichBlockPhoto{ + Photo: []PhotoSize{{FileID: "img1", Width: 10, Height: 10}}, + Caption: &RichBlockCaption{Text: RichTextPlain("Fig. 1")}, + }, + }, + }, + { + Blocks: []RichBlock{par("שלום")}, + IsRTL: true, + }, + } { + b, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out RichMessage + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + b2, err := json.Marshal(out) + if err != nil { + t.Fatalf("remarshal: %v", err) + } + if string(b) != string(b2) { + t.Fatalf("not stable:\n %s\n %s", b, b2) + } + } +} + +func TestRichBlockTags(t *testing.T) { + // tags per spec: heading, pre, blockquote, pullquote + cases := map[string]RichBlock{ + "heading": RichBlockSectionHeading{RichTextPlain("h"), 2}, + "pre": RichBlockPreformatted{Text: RichTextPlain("x")}, + "blockquote": RichBlockQuotation{Blocks: []RichBlock{par("q")}}, + "pullquote": RichBlockPullQuotation{Text: RichTextPlain("p")}, + } + for want, block := range cases { + b, _ := json.Marshal(block) + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != want { + t.Fatalf("expected type %q, got %s", want, b) + } + } +} + +func TestRichBlockOptionalFieldsOmitted(t *testing.T) { + // nil credit/caption and false flags must not appear in the JSON + for _, c := range []struct { + block RichBlock + bad []string + }{ + {RichBlockQuotation{Blocks: []RichBlock{par("q")}}, []string{"credit"}}, + {RichBlockPullQuotation{Text: RichTextPlain("p")}, []string{"credit"}}, + {RichBlockPhoto{Photo: []PhotoSize{{FileID: "p"}}}, []string{"caption", "has_spoiler"}}, + {RichBlockTable{Cells: [][]RichBlockTableCell{}}, []string{"caption", "is_bordered", "is_striped"}}, + {RichBlockDetails{Summary: RichTextPlain("s")}, []string{"is_open"}}, + } { + b, _ := json.Marshal(c.block) + for _, key := range c.bad { + if strings.Contains(string(b), `"`+key+`"`) { + t.Fatalf("%T: %q must be omitted: %s", c.block, key, b) + } + } + } + // same for RichMessage.is_rtl + b, _ := json.Marshal(RichMessage{Blocks: []RichBlock{par("x")}}) + if strings.Contains(string(b), "is_rtl") { + t.Fatalf("is_rtl must be omitted: %s", b) + } +} + +func TestRichBlockDividerHasNoContent(t *testing.T) { + b, _ := json.Marshal(RichBlockDivider{}) + var m map[string]any + _ = json.Unmarshal(b, &m) + if len(m) != 1 { + t.Fatalf("divider must only have type field: %s", b) + } + if m["type"] != "divider" { + t.Fatalf("unexpected type: %s", b) + } +} + +func TestRichBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) { + raw := []byte(`{"type":"future_tag","text":"hello"}`) + b, err := UnmarshalRichBlock(raw) + if err != nil { + t.Fatalf("forward-compat failed: %v", err) + } + w, ok := b.(RichBlockWrap) + if !ok || w.Tag != "future_tag" { + t.Fatalf("expected RichBlockWrap{future_tag}, got %T", b) + } +} + +func TestRichBlockUnknownTypeWithoutTextIsError(t *testing.T) { + raw := []byte(`{"type":"mystery_leaf","value":42}`) + _, err := UnmarshalRichBlock(raw) + if err == nil { + t.Fatal("expected error for unknown type without text") + } +} diff --git a/tgfmt/richtext/richtext.go b/tgapi/richtext.go similarity index 54% rename from tgfmt/richtext/richtext.go rename to tgapi/richtext.go index 43f1016..dab5f39 100644 --- a/tgfmt/richtext/richtext.go +++ b/tgapi/richtext.go @@ -1,78 +1,76 @@ -package richtext +package tgapi import ( "encoding/json" "fmt" - - "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// RichText — узел дерева форматированного текста: строка, массив или -// один из типизированных объектов ниже. +// Rich messages (Bot API 10.1), receive side: the RichText*/RichBlock* types +// mirror what the server sends in Message.rich_message, plus their parsers. +// These types intentionally have no constructors: sending goes only through +// InputRichMessage (html/markdown), and HTML generation lives in tgfmt +// (rich.go). Names follow the API objects; the exception is +// RichBlockQuotation (officially RichBlockBlockQuotation, the double Block +// is dropped). + +// RichText is a node of the rich formatted text tree: a plain string, an +// array, or one of the typed objects below. type RichText interface { isRichText() } // --------------------------------------------------------------------------- -// Базовые формы: строка и массив +// Base forms: string and array // --------------------------------------------------------------------------- -type String string +// RichTextPlain is a plain text leaf. +type RichTextPlain string -func (String) isRichText() {} +func (RichTextPlain) isRichText() {} -type Array []RichText +// RichTextArray is a concatenation of rich text nodes. +type RichTextArray []RichText -func (Array) isRichText() {} +func (RichTextArray) isRichText() {} // --------------------------------------------------------------------------- -// Узлы только с полем text. Их 9; различает только тег. +// Nodes with only a text field. There are 9; only the tag differs. // bold italic underline strikethrough spoiler subscript superscript marked code // --------------------------------------------------------------------------- -// Wrap покрывает все «чистые» оборачивающие узлы одним типом. -type Wrap struct { +// RichTextWrap covers all "pure" wrapper nodes with a single type. +type RichTextWrap struct { Tag string // "bold", "italic", ... Text RichText } -func (Wrap) isRichText() {} +func (RichTextWrap) isRichText() {} -func (w Wrap) MarshalJSON() ([]byte, error) { +func (w RichTextWrap) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` }{w.Tag, w.Text}) } -var wrapTags = map[string]bool{ +var richTextWrapTags = map[string]bool{ "bold": true, "italic": true, "underline": true, "strikethrough": true, "spoiler": true, "subscript": true, "superscript": true, "marked": true, "code": true, } -// Удобные конструкторы для wrap-узлов. -func Bold(t RichText) Wrap { return Wrap{"bold", t} } -func Italic(t RichText) Wrap { return Wrap{"italic", t} } -func Underline(t RichText) Wrap { return Wrap{"underline", t} } -func Strikethrough(t RichText) Wrap { return Wrap{"strikethrough", t} } -func Spoiler(t RichText) Wrap { return Wrap{"spoiler", t} } -func Subscript(t RichText) Wrap { return Wrap{"subscript", t} } -func Superscript(t RichText) Wrap { return Wrap{"superscript", t} } -func Marked(t RichText) Wrap { return Wrap{"marked", t} } -func Code(t RichText) Wrap { return Wrap{"code", t} } - // --------------------------------------------------------------------------- -// Узлы с text + одно строковое доп. поле. +// Nodes with text + one extra string field. // --------------------------------------------------------------------------- -type URL struct { +// RichTextURL is rich text linking to a URL. +type RichTextURL struct { Text RichText URL string } -func (URL) isRichText() {} -func (v URL) MarshalJSON() ([]byte, error) { +func (RichTextURL) isRichText() {} +func (v RichTextURL) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -80,13 +78,14 @@ func (v URL) MarshalJSON() ([]byte, error) { }{"url", v.Text, v.URL}) } -type EmailAddress struct { +// RichTextEmailAddress is rich text linking to an email address. +type RichTextEmailAddress struct { Text RichText EmailAddress string } -func (EmailAddress) isRichText() {} -func (v EmailAddress) MarshalJSON() ([]byte, error) { +func (RichTextEmailAddress) isRichText() {} +func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -94,13 +93,14 @@ func (v EmailAddress) MarshalJSON() ([]byte, error) { }{"email_address", v.Text, v.EmailAddress}) } -type PhoneNumber struct { +// RichTextPhoneNumber is rich text linking to a phone number. +type RichTextPhoneNumber struct { Text RichText PhoneNumber string } -func (PhoneNumber) isRichText() {} -func (v PhoneNumber) MarshalJSON() ([]byte, error) { +func (RichTextPhoneNumber) isRichText() {} +func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -108,13 +108,14 @@ func (v PhoneNumber) MarshalJSON() ([]byte, error) { }{"phone_number", v.Text, v.PhoneNumber}) } -type BankCardNumber struct { +// RichTextBankCardNumber is rich text marked as a bank card number. +type RichTextBankCardNumber struct { Text RichText BankCardNumber string } -func (BankCardNumber) isRichText() {} -func (v BankCardNumber) MarshalJSON() ([]byte, error) { +func (RichTextBankCardNumber) isRichText() {} +func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -122,13 +123,14 @@ func (v BankCardNumber) MarshalJSON() ([]byte, error) { }{"bank_card_number", v.Text, v.BankCardNumber}) } -type Mention struct { +// RichTextMention is rich text mentioning a user by username. +type RichTextMention struct { Text RichText Username string } -func (Mention) isRichText() {} -func (v Mention) MarshalJSON() ([]byte, error) { +func (RichTextMention) isRichText() {} +func (v RichTextMention) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -136,13 +138,14 @@ func (v Mention) MarshalJSON() ([]byte, error) { }{"mention", v.Text, v.Username}) } -type Hashtag struct { +// RichTextHashtag is rich text marked as a hashtag. +type RichTextHashtag struct { Text RichText Hashtag string } -func (Hashtag) isRichText() {} -func (v Hashtag) MarshalJSON() ([]byte, error) { +func (RichTextHashtag) isRichText() {} +func (v RichTextHashtag) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -150,13 +153,14 @@ func (v Hashtag) MarshalJSON() ([]byte, error) { }{"hashtag", v.Text, v.Hashtag}) } -type Cashtag struct { +// RichTextCashtag is rich text marked as a cashtag. +type RichTextCashtag struct { Text RichText Cashtag string } -func (Cashtag) isRichText() {} -func (v Cashtag) MarshalJSON() ([]byte, error) { +func (RichTextCashtag) isRichText() {} +func (v RichTextCashtag) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -164,13 +168,14 @@ func (v Cashtag) MarshalJSON() ([]byte, error) { }{"cashtag", v.Text, v.Cashtag}) } -type BotCommand struct { +// RichTextBotCommand is rich text marked as a bot command. +type RichTextBotCommand struct { Text RichText BotCommand string } -func (BotCommand) isRichText() {} -func (v BotCommand) MarshalJSON() ([]byte, error) { +func (RichTextBotCommand) isRichText() {} +func (v RichTextBotCommand) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -178,13 +183,14 @@ func (v BotCommand) MarshalJSON() ([]byte, error) { }{"bot_command", v.Text, v.BotCommand}) } -type AnchorLink struct { +// RichTextAnchorLink is rich text linking to a named anchor in the same message. +type RichTextAnchorLink struct { Text RichText AnchorName string } -func (AnchorLink) isRichText() {} -func (v AnchorLink) MarshalJSON() ([]byte, error) { +func (RichTextAnchorLink) isRichText() {} +func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -192,13 +198,14 @@ func (v AnchorLink) MarshalJSON() ([]byte, error) { }{"anchor_link", v.Text, v.AnchorName}) } -type Reference struct { +// RichTextReference is rich text marked as a named reference target. +type RichTextReference struct { Text RichText Name string } -func (Reference) isRichText() {} -func (v Reference) MarshalJSON() ([]byte, error) { +func (RichTextReference) isRichText() {} +func (v RichTextReference) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -206,13 +213,14 @@ func (v Reference) MarshalJSON() ([]byte, error) { }{"reference", v.Text, v.Name}) } -type ReferenceLink struct { +// RichTextReferenceLink is rich text linking to a named reference. +type RichTextReferenceLink struct { Text RichText ReferenceName string } -func (ReferenceLink) isRichText() {} -func (v ReferenceLink) MarshalJSON() ([]byte, error) { +func (RichTextReferenceLink) isRichText() {} +func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -221,17 +229,18 @@ func (v ReferenceLink) MarshalJSON() ([]byte, error) { } // --------------------------------------------------------------------------- -// Узлы с text + несколько/нестроковых полей. +// Nodes with text + multiple/non-string fields. // --------------------------------------------------------------------------- -type DateTime struct { +// RichTextDateTime is rich text bound to a point in time with a display format. +type RichTextDateTime struct { Text RichText UnixTime int64 DateTimeFormat string } -func (DateTime) isRichText() {} -func (v DateTime) MarshalJSON() ([]byte, error) { +func (RichTextDateTime) isRichText() {} +func (v RichTextDateTime) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Text RichText `json:"text"` @@ -240,31 +249,33 @@ func (v DateTime) MarshalJSON() ([]byte, error) { }{"date_time", v.Text, v.UnixTime, v.DateTimeFormat}) } -type TextMention struct { +// RichTextTextMention is rich text mentioning a user without a username. +type RichTextTextMention struct { Text RichText - User tgapi.User + User User } -func (TextMention) isRichText() {} -func (v TextMention) MarshalJSON() ([]byte, error) { +func (RichTextTextMention) isRichText() {} +func (v RichTextTextMention) MarshalJSON() ([]byte, error) { return json.Marshal(struct { - Type string `json:"type"` - Text RichText `json:"text"` - User tgapi.User `json:"user"` + Type string `json:"type"` + Text RichText `json:"text"` + User User `json:"user"` }{"text_mention", v.Text, v.User}) } // --------------------------------------------------------------------------- -// ЛИСТЬЯ: без поля text. +// LEAVES: no text field. // --------------------------------------------------------------------------- -type CustomEmoji struct { +// RichTextCustomEmoji is a custom emoji leaf with alternative text. +type RichTextCustomEmoji struct { CustomEmojiID string AlternativeText string } -func (CustomEmoji) isRichText() {} -func (v CustomEmoji) MarshalJSON() ([]byte, error) { +func (RichTextCustomEmoji) isRichText() {} +func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` CustomEmojiID string `json:"custom_emoji_id"` @@ -272,24 +283,26 @@ func (v CustomEmoji) MarshalJSON() ([]byte, error) { }{"custom_emoji", v.CustomEmojiID, v.AlternativeText}) } -type MathematicalExpression struct { +// RichTextMathematicalExpression is an inline mathematical expression leaf. +type RichTextMathematicalExpression struct { Expression string } -func (MathematicalExpression) isRichText() {} -func (v MathematicalExpression) MarshalJSON() ([]byte, error) { +func (RichTextMathematicalExpression) isRichText() {} +func (v RichTextMathematicalExpression) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Expression string `json:"expression"` }{"mathematical_expression", v.Expression}) } -type Anchor struct { +// RichTextAnchor is a named anchor leaf that anchor links can point to. +type RichTextAnchor struct { Name string } -func (Anchor) isRichText() {} -func (v Anchor) MarshalJSON() ([]byte, error) { +func (RichTextAnchor) isRichText() {} +func (v RichTextAnchor) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Type string `json:"type"` Name string `json:"name"` @@ -297,21 +310,24 @@ func (v Anchor) MarshalJSON() ([]byte, error) { } // --------------------------------------------------------------------------- -// Разбор JSON -> RichText +// JSON -> RichText parsing // --------------------------------------------------------------------------- -func Unmarshal(data []byte) (RichText, error) { - // 1. строка +// UnmarshalRichText parses a RichText tree from JSON: a string, an array, or +// a typed object. Unknown object types that carry a text field are preserved +// as RichTextWrap for forward compatibility. +func UnmarshalRichText(data []byte) (RichText, error) { + // 1. string var s string if err := json.Unmarshal(data, &s); err == nil { - return String(s), nil + return RichTextPlain(s), nil } - // 2. массив + // 2. array var raw []json.RawMessage if err := json.Unmarshal(data, &raw); err == nil { - arr := make(Array, len(raw)) + arr := make(RichTextArray, len(raw)) for i, it := range raw { - rt, err := Unmarshal(it) + rt, err := UnmarshalRichText(it) if err != nil { return nil, err } @@ -319,7 +335,7 @@ func Unmarshal(data []byte) (RichText, error) { } return arr, nil } - // 3. объект -> смотрим type, попутно вытаскиваем сырой text + // 3. object -> dispatch on type, grabbing the raw text along the way var head struct { Type string `json:"type"` Text json.RawMessage `json:"text"` @@ -328,17 +344,17 @@ func Unmarshal(data []byte) (RichText, error) { return nil, fmt.Errorf("richtext: not a string, array or object: %w", err) } - // Рекурсивно разбираем вложенный text, если он есть. + // Recursively parse the nested text, if any. var inner RichText if len(head.Text) > 0 { var err error - if inner, err = Unmarshal(head.Text); err != nil { + if inner, err = UnmarshalRichText(head.Text); err != nil { return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err) } } - if wrapTags[head.Type] { - return Wrap{Tag: head.Type, Text: inner}, nil + if richTextWrapTags[head.Type] { + return RichTextWrap{Tag: head.Type, Text: inner}, nil } switch head.Type { @@ -349,107 +365,107 @@ func Unmarshal(data []byte) (RichText, error) { if err := json.Unmarshal(data, &v); err != nil { return nil, err } - return URL{inner, v.URL}, nil + return RichTextURL{inner, v.URL}, nil case "email_address": var v struct { V string `json:"email_address"` } _ = json.Unmarshal(data, &v) - return EmailAddress{inner, v.V}, nil + return RichTextEmailAddress{inner, v.V}, nil case "phone_number": var v struct { V string `json:"phone_number"` } _ = json.Unmarshal(data, &v) - return PhoneNumber{inner, v.V}, nil + return RichTextPhoneNumber{inner, v.V}, nil case "bank_card_number": var v struct { V string `json:"bank_card_number"` } _ = json.Unmarshal(data, &v) - return BankCardNumber{inner, v.V}, nil + return RichTextBankCardNumber{inner, v.V}, nil case "mention": var v struct { V string `json:"username"` } _ = json.Unmarshal(data, &v) - return Mention{inner, v.V}, nil + return RichTextMention{inner, v.V}, nil case "hashtag": var v struct { V string `json:"hashtag"` } _ = json.Unmarshal(data, &v) - return Hashtag{inner, v.V}, nil + return RichTextHashtag{inner, v.V}, nil case "cashtag": var v struct { V string `json:"cashtag"` } _ = json.Unmarshal(data, &v) - return Cashtag{inner, v.V}, nil + return RichTextCashtag{inner, v.V}, nil case "bot_command": var v struct { V string `json:"bot_command"` } _ = json.Unmarshal(data, &v) - return BotCommand{inner, v.V}, nil + return RichTextBotCommand{inner, v.V}, nil case "anchor_link": var v struct { V string `json:"anchor_name"` } _ = json.Unmarshal(data, &v) - return AnchorLink{inner, v.V}, nil + return RichTextAnchorLink{inner, v.V}, nil case "reference": var v struct { V string `json:"name"` } _ = json.Unmarshal(data, &v) - return Reference{inner, v.V}, nil + return RichTextReference{inner, v.V}, nil case "reference_link": var v struct { V string `json:"reference_name"` } _ = json.Unmarshal(data, &v) - return ReferenceLink{inner, v.V}, nil + return RichTextReferenceLink{inner, v.V}, nil case "date_time": var v struct { UnixTime int64 `json:"unix_time"` DateTimeFormat string `json:"date_time_format"` } _ = json.Unmarshal(data, &v) - return DateTime{inner, v.UnixTime, v.DateTimeFormat}, nil + return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil case "text_mention": var v struct { - User tgapi.User `json:"user"` + User User `json:"user"` } _ = json.Unmarshal(data, &v) - return TextMention{inner, v.User}, nil + return RichTextTextMention{inner, v.User}, nil - // --- листья без text --- + // --- leaves without text --- case "custom_emoji": var v struct { ID string `json:"custom_emoji_id"` Alt string `json:"alternative_text"` } _ = json.Unmarshal(data, &v) - return CustomEmoji{v.ID, v.Alt}, nil + return RichTextCustomEmoji{v.ID, v.Alt}, nil case "mathematical_expression": var v struct { Expression string `json:"expression"` } _ = json.Unmarshal(data, &v) - return MathematicalExpression{v.Expression}, nil + return RichTextMathematicalExpression{v.Expression}, nil case "anchor": var v struct { Name string `json:"name"` } _ = json.Unmarshal(data, &v) - return Anchor{v.Name}, nil + return RichTextAnchor{v.Name}, nil default: - // forward-compat: неизвестный тег с полем text сохраняем как Wrap, - // без text — как ошибку (нельзя угадать форму). + // forward-compat: keep an unknown tag with a text field as + // RichTextWrap; without text it is an error (the shape cannot be guessed). if inner != nil { - return Wrap{Tag: head.Type, Text: inner}, nil + return RichTextWrap{Tag: head.Type, Text: inner}, nil } return nil, fmt.Errorf("richtext: unknown type %q", head.Type) } diff --git a/tgapi/richtext_test.go b/tgapi/richtext_test.go new file mode 100644 index 0000000..ceda077 --- /dev/null +++ b/tgapi/richtext_test.go @@ -0,0 +1,70 @@ +package tgapi + +import ( + "encoding/json" + "testing" +) + +func roundtripRichText(t *testing.T, in RichText) { + t.Helper() + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out, err := UnmarshalRichText(b) + if err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + b2, err := json.Marshal(out) + if err != nil { + t.Fatalf("remarshal: %v", err) + } + if string(b) != string(b2) { + t.Fatalf("not stable:\n %s\n %s", b, b2) + } +} + +func TestRichTextRoundtrip(t *testing.T) { + cases := []RichText{ + RichTextPlain("hello"), + RichTextArray{RichTextPlain("a "), RichTextWrap{"bold", RichTextPlain("b")}, RichTextPlain(" c")}, + RichTextWrap{"bold", RichTextWrap{"italic", RichTextPlain("nested")}}, + RichTextURL{RichTextPlain("Anthropic"), "https://anthropic.com"}, + RichTextCustomEmoji{"5368324170671202286", "👍"}, + RichTextMathematicalExpression{"x^2 + y^2"}, + RichTextAnchor{"chapter-1"}, + RichTextDateTime{RichTextPlain("22:45 tomorrow"), 1647531900, "wDT"}, + RichTextTextMention{RichTextPlain("Bob"), User{ID: 42, FirstName: "Bob"}}, + RichTextAnchorLink{RichTextPlain("back to top"), ""}, + RichTextReference{RichTextPlain("ref"), "note-1"}, + // deep nesting + RichTextWrap{"bold", RichTextArray{ + RichTextPlain("bold and "), + RichTextWrap{"italic", RichTextWrap{"underline", RichTextPlain("deep")}}, + RichTextWrap{"spoiler", RichTextCustomEmoji{"1", "x"}}, + }}, + } + for _, c := range cases { + roundtripRichText(t, c) + } +} + +func TestRichTextPlainFormsAreBare(t *testing.T) { + b, _ := json.Marshal(RichTextPlain("hi")) + if string(b) != `"hi"` { + t.Fatalf("string should be bare: %s", b) + } + b, _ = json.Marshal(RichTextArray{RichTextPlain("a"), RichTextPlain("b")}) + if string(b) != `["a","b"]` { + t.Fatalf("array should be bare: %s", b) + } +} + +func TestRichTextLeafHasNoText(t *testing.T) { + b, _ := json.Marshal(RichTextAnchor{"x"}) + var m map[string]any + _ = json.Unmarshal(b, &m) + if _, ok := m["text"]; ok { + t.Fatalf("anchor must not have text field: %s", b) + } +} diff --git a/tgapi/types.go b/tgapi/types.go index 291fa2d..ec61314 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -255,6 +255,11 @@ type ChatJoinRequest struct { Date int64 `json:"date"` Bio *string `json:"bio,omitempty"` InviteLink *ChatInviteLink `json:"invite_link,omitempty"` + + // QueryID identifies the join request query; present only for bots + // assigned to process join requests. When set, the bot must call + // SendChatJoinRequestWebApp or AnswerChatJoinRequestQuery within 10 seconds. + QueryID *string `json:"query_id,omitempty"` // Since: Bot API 10.1 } // Location represents a point on the map. diff --git a/tgapi/users_types.go b/tgapi/users_types.go index fb624d5..5aae525 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -22,6 +22,10 @@ type User struct { AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` // Since: Bot API 9.4 CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6 SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0 + + // SupportsJoinRequestQueries reports that the bot supports join request + // queries and can be assigned to process them. Returned only in getMe. + SupportsJoinRequestQueries *bool `json:"supports_join_request_queries,omitempty"` // Since: Bot API 10.1 } // UserProfilePhotos represents a user's profile photos. diff --git a/tgfmt/rich.go b/tgfmt/rich.go new file mode 100644 index 0000000..168f20e --- /dev/null +++ b/tgfmt/rich.go @@ -0,0 +1,492 @@ +package tgfmt + +import ( + "fmt" + "strconv" + "strings" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// Rich is an inline fragment of rich-message HTML (Bot API 10.1). +// Raw text enters through NewRich, which escapes it; fragments compose as-is. +type Rich string + +// RichBlock is a block-level fragment of rich-message HTML. Block +// constructors accept only Rich arguments, so invalid nesting (a block +// inside inline content) does not compile. +type RichBlock string + +// RichItem is any rich-message fragment: Rich or RichBlock. Both are valid +// at the top level of a message — Telegram merges adjacent inline content +// into paragraphs. +type RichItem interface{ richItem() string } + +func (r Rich) richItem() string { return string(r) } +func (r RichBlock) richItem() string { return string(r) } + +// RichHTML concatenates fragments into the final rich-message HTML string. +func RichHTML(items ...RichItem) string { + var b strings.Builder + for _, item := range items { + b.WriteString(item.richItem()) + } + return b.String() +} + +// RichMessage builds a ready-to-send InputRichMessage from fragments. +// SkipEntityDetection is enabled so the server does not add auto-detected +// entities; set IsRTL on the result if needed. +func RichMessage(items ...RichItem) tgapi.InputRichMessage { + return tgapi.InputRichMessage{ + HTML: RichHTML(items...), + SkipEntityDetection: true, + } +} + +func richJoin(items ...Rich) Rich { + var out Rich + for _, item := range items { + out += item + } + return out +} +func richJoinSep(sep Rich, items ...Rich) Rich { + var out Rich + for i, item := range items { + if i > 0 { + out += sep + } + out += item + } + return out +} +func richBlocksJoin(items ...RichBlock) RichBlock { + var out RichBlock + for _, item := range items { + out += item + } + return out +} + +// openTag omits the space when there are no attributes. +func openTag(name string, attrs []string) string { + if len(attrs) == 0 { + return "<" + name + ">" + } + return "<" + name + " " + strings.Join(attrs, " ") + ">" +} + +func cite(credit Rich) Rich { + if credit == "" { + return "" + } + return "" + credit + "" +} + +// NewRich escapes raw text and returns it as an inline fragment. +func NewRich(text string) Rich { return Rich(escapeHTML(text)) } + +// Bold wraps the fragment in . +func (r Rich) Bold() Rich { return "" + r + "" } + +// Italic wraps the fragment in . +func (r Rich) Italic() Rich { return "" + r + "" } + +// Underline wraps the fragment in . +func (r Rich) Underline() Rich { return "" + r + "" } + +// Strike wraps the fragment in.
+func (r Rich) Code() Rich { return "" + r + "" }
+
+// Mark wraps the fragment in .
+func (r Rich) Mark() Rich { return "" + r + "" }
+
+// Sub wraps the fragment in .
+func (r Rich) Sub() Rich { return "" + r + "" }
+
+// Sup wraps the fragment in .
+func (r Rich) Sup() Rich { return "" + r + "" }
+
+// Spoiler wraps the fragment in .
+func (r Rich) Spoiler() Rich { return "" + r + " " }
+
+// Link wraps the fragment in a hyperlink to url.
+func (r Rich) Link(url string) Rich { return `` + r + "" }
+
+// Email wraps the fragment in a mailto: link.
+func (r Rich) Email(email string) Rich { return r.Link("mailto:" + email) }
+
+// Phone wraps the fragment in a tel: link.
+func (r Rich) Phone(phone string) Rich { return r.Link("tel:" + phone) }
+
+// Mention wraps the fragment in an inline user mention link.
+func (r Rich) Mention(userID int64) Rich {
+ return r.Link("tg://user?id=" + strconv.FormatInt(userID, 10))
+}
+
+// Anchor marks the fragment as a named anchor ().
+func (r Rich) Anchor(name string) Rich { return `` + r + "" }
+
+// AnchorLink wraps the fragment in an in-document link to a named anchor
+// or reference; the server resolves which one by the target name.
+func (r Rich) AnchorLink(anchor string) Rich { return r.Link("#" + anchor) }
+
+// Ref wraps the fragment in a to the named reference.
+func (r Rich) Ref(ref string) Rich {
+ return `` + r + " "
+}
+
+// Emoji builds a custom emoji fragment with alt text as fallback.
+func Emoji(emojiID, alt string) Rich {
+ return `` + escapeRich(alt) + ` `
+}
+
+// Time marks the fragment as a bound to t.
+func (r Rich) Time(t time.Time) Rich {
+ return `` + r + " "
+}
+
+// TimeFormat marks the fragment as a with an explicit display format.
+func (r Rich) TimeFormat(t time.Time, format string) Rich {
+ return `` + r + " "
+}
+
+// Math wraps the fragment in an inline expression.
+func (r Rich) Math() Rich { return `` + r + ` ` }
+
+// Br returns a line break fragment.
+func Br() Rich { return "
" }
+
+// H1 builds a level-1 heading block.
+func H1(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// H2 builds a level-2 heading block.
+func H2(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// H3 builds a level-3 heading block.
+func H3(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// H4 builds a level-4 heading block.
+func H4(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// H5 builds a level-5 heading block.
+func H5(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// H6 builds a level-6 heading block.
+func H6(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// P builds a paragraph block.
+func P(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// Pre builds a preformatted code block.
+func Pre(items ...Rich) RichBlock { return RichBlock("" + richJoin(items...) + "
") }
+
+// PreCode builds a preformatted code block tagged with a language.
+func PreCode(lang string, items ...Rich) RichBlock {
+ return RichBlock(`` + richJoin(items...) + `
`)
+}
+
+// Footer builds a footer block.
+func Footer(items ...Rich) RichBlock { return RichBlock("") }
+
+// Hr builds a divider block.
+func Hr() RichBlock { return "
" }
+
+// AnchorBlock builds a standalone named anchor between blocks: .
+func AnchorBlock(name string) RichBlock {
+ return RichBlock(``)
+}
+
+// LiItem is a list item under construction for Ul or Ol.
+type LiItem struct {
+ text Rich
+ value int
+ typ string
+ checkbox bool
+ checked bool
+}
+
+// Li builds a list item from inline fragments.
+func Li(items ...Rich) LiItem { return LiItem{text: richJoin(items...)} }
+
+// LiCheckbox builds a checkbox list item.
+func LiCheckbox(checked bool, items ...Rich) LiItem {
+ return LiItem{text: richJoin(items...), checkbox: true, checked: checked}
+}
+
+// SetValue sets the explicit ordinal of the item (like ).
+func (l LiItem) SetValue(val int) LiItem {
+ l.value = val
+ return l
+}
+
+// SetType sets the numbering type of the item: "1", "a", "A", "i", "I".
+func (l LiItem) SetType(t string) LiItem {
+ l.typ = t
+ return l
+}
+func (l LiItem) build() Rich {
+ if l.checkbox {
+ input := Rich(``)
+ if l.checked {
+ input = ``
+ }
+ return " " + input + l.text + " "
+ }
+ attrs := make([]string, 0, 2)
+ if l.value != 0 {
+ attrs = append(attrs, `value="`+strconv.Itoa(l.value)+`"`)
+ }
+ if l.typ != "" {
+ attrs = append(attrs, `type="`+escapeHTML(l.typ)+`"`)
+ }
+ return Rich(openTag("li", attrs)) + l.text + ""
+}
+func joinLiItems(items []LiItem) Rich {
+ var out Rich
+ for _, item := range items {
+ out += item.build()
+ }
+ return out
+}
+
+// Ul builds an unordered list block.
+func Ul(items ...LiItem) RichBlock {
+ return RichBlock(`` + joinLiItems(items) + `
`)
+}
+
+// OlOpts holds the numbering attributes.
+type OlOpts struct {
+ Start int
+ Type string
+ Reversed bool
+}
+
+// Ol builds an ordered list block. Item labels are rendered by the server.
+func Ol(opts OlOpts, items ...LiItem) RichBlock {
+ attrs := make([]string, 0, 3)
+ if opts.Start > 0 {
+ attrs = append(attrs, `start="`+strconv.Itoa(opts.Start)+`"`)
+ }
+ if opts.Type != "" {
+ attrs = append(attrs, `type="`+escapeHTML(opts.Type)+`"`)
+ }
+ if opts.Reversed {
+ attrs = append(attrs, "reversed")
+ }
+ return RichBlock(openTag("ol", attrs) + string(joinLiItems(items)) + "
")
+}
+
+// Blockquote builds a block quotation: lines are joined with
(as in the
+// official HTML example) and credit renders as a trailing .
+func Blockquote(credit Rich, lines ...Rich) RichBlock {
+ return RichBlock(`` + richJoinSep(Br(), lines...) + cite(credit) + `
`)
+}
+
+// Aside builds a pull quote (