(new): rich messages, tgfmt DSL, Bot API 10.1
(fix): editMessageText rich_message type (tests): rich and API 10.1 coverage (doc): rich godoc, CHANGELOG v1.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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: "<p>hi</p>",
|
||||
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: "<p>hi</p>"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+18
-1
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichTextPlain is a plain text leaf.
|
||||
type RichTextPlain string
|
||||
|
||||
func (RichTextPlain) isRichText() {}
|
||||
|
||||
// RichTextArray is a concatenation of rich text nodes.
|
||||
type RichTextArray []RichText
|
||||
|
||||
func (RichTextArray) isRichText() {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nodes with only a text field. There are 9; only the tag differs.
|
||||
// bold italic underline strikethrough spoiler subscript superscript marked code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichTextWrap covers all "pure" wrapper nodes with a single type.
|
||||
type RichTextWrap struct {
|
||||
Tag string // "bold", "italic", ...
|
||||
Text RichText
|
||||
}
|
||||
|
||||
func (RichTextWrap) isRichText() {}
|
||||
|
||||
func (w RichTextWrap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
}{w.Tag, w.Text})
|
||||
}
|
||||
|
||||
var richTextWrapTags = map[string]bool{
|
||||
"bold": true, "italic": true, "underline": true,
|
||||
"strikethrough": true, "spoiler": true, "subscript": true,
|
||||
"superscript": true, "marked": true, "code": true,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nodes with text + one extra string field.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichTextURL is rich text linking to a URL.
|
||||
type RichTextURL struct {
|
||||
Text RichText
|
||||
URL string
|
||||
}
|
||||
|
||||
func (RichTextURL) isRichText() {}
|
||||
func (v RichTextURL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
URL string `json:"url"`
|
||||
}{"url", v.Text, v.URL})
|
||||
}
|
||||
|
||||
// RichTextEmailAddress is rich text linking to an email address.
|
||||
type RichTextEmailAddress struct {
|
||||
Text RichText
|
||||
EmailAddress string
|
||||
}
|
||||
|
||||
func (RichTextEmailAddress) isRichText() {}
|
||||
func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
}{"email_address", v.Text, v.EmailAddress})
|
||||
}
|
||||
|
||||
// RichTextPhoneNumber is rich text linking to a phone number.
|
||||
type RichTextPhoneNumber struct {
|
||||
Text RichText
|
||||
PhoneNumber string
|
||||
}
|
||||
|
||||
func (RichTextPhoneNumber) isRichText() {}
|
||||
func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}{"phone_number", v.Text, v.PhoneNumber})
|
||||
}
|
||||
|
||||
// RichTextBankCardNumber is rich text marked as a bank card number.
|
||||
type RichTextBankCardNumber struct {
|
||||
Text RichText
|
||||
BankCardNumber string
|
||||
}
|
||||
|
||||
func (RichTextBankCardNumber) isRichText() {}
|
||||
func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BankCardNumber string `json:"bank_card_number"`
|
||||
}{"bank_card_number", v.Text, v.BankCardNumber})
|
||||
}
|
||||
|
||||
// RichTextMention is rich text mentioning a user by username.
|
||||
type RichTextMention struct {
|
||||
Text RichText
|
||||
Username string
|
||||
}
|
||||
|
||||
func (RichTextMention) isRichText() {}
|
||||
func (v RichTextMention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Username string `json:"username"`
|
||||
}{"mention", v.Text, v.Username})
|
||||
}
|
||||
|
||||
// RichTextHashtag is rich text marked as a hashtag.
|
||||
type RichTextHashtag struct {
|
||||
Text RichText
|
||||
Hashtag string
|
||||
}
|
||||
|
||||
func (RichTextHashtag) isRichText() {}
|
||||
func (v RichTextHashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Hashtag string `json:"hashtag"`
|
||||
}{"hashtag", v.Text, v.Hashtag})
|
||||
}
|
||||
|
||||
// RichTextCashtag is rich text marked as a cashtag.
|
||||
type RichTextCashtag struct {
|
||||
Text RichText
|
||||
Cashtag string
|
||||
}
|
||||
|
||||
func (RichTextCashtag) isRichText() {}
|
||||
func (v RichTextCashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Cashtag string `json:"cashtag"`
|
||||
}{"cashtag", v.Text, v.Cashtag})
|
||||
}
|
||||
|
||||
// RichTextBotCommand is rich text marked as a bot command.
|
||||
type RichTextBotCommand struct {
|
||||
Text RichText
|
||||
BotCommand string
|
||||
}
|
||||
|
||||
func (RichTextBotCommand) isRichText() {}
|
||||
func (v RichTextBotCommand) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BotCommand string `json:"bot_command"`
|
||||
}{"bot_command", v.Text, v.BotCommand})
|
||||
}
|
||||
|
||||
// RichTextAnchorLink is rich text linking to a named anchor in the same message.
|
||||
type RichTextAnchorLink struct {
|
||||
Text RichText
|
||||
AnchorName string
|
||||
}
|
||||
|
||||
func (RichTextAnchorLink) isRichText() {}
|
||||
func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
AnchorName string `json:"anchor_name"`
|
||||
}{"anchor_link", v.Text, v.AnchorName})
|
||||
}
|
||||
|
||||
// RichTextReference is rich text marked as a named reference target.
|
||||
type RichTextReference struct {
|
||||
Text RichText
|
||||
Name string
|
||||
}
|
||||
|
||||
func (RichTextReference) isRichText() {}
|
||||
func (v RichTextReference) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Name string `json:"name"`
|
||||
}{"reference", v.Text, v.Name})
|
||||
}
|
||||
|
||||
// RichTextReferenceLink is rich text linking to a named reference.
|
||||
type RichTextReferenceLink struct {
|
||||
Text RichText
|
||||
ReferenceName string
|
||||
}
|
||||
|
||||
func (RichTextReferenceLink) isRichText() {}
|
||||
func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
ReferenceName string `json:"reference_name"`
|
||||
}{"reference_link", v.Text, v.ReferenceName})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nodes with text + multiple/non-string fields.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichTextDateTime is rich text bound to a point in time with a display format.
|
||||
type RichTextDateTime struct {
|
||||
Text RichText
|
||||
UnixTime int64
|
||||
DateTimeFormat string
|
||||
}
|
||||
|
||||
func (RichTextDateTime) isRichText() {}
|
||||
func (v RichTextDateTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
UnixTime int64 `json:"unix_time"`
|
||||
DateTimeFormat string `json:"date_time_format"`
|
||||
}{"date_time", v.Text, v.UnixTime, v.DateTimeFormat})
|
||||
}
|
||||
|
||||
// RichTextTextMention is rich text mentioning a user without a username.
|
||||
type RichTextTextMention struct {
|
||||
Text RichText
|
||||
User User
|
||||
}
|
||||
|
||||
func (RichTextTextMention) isRichText() {}
|
||||
func (v RichTextTextMention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
User User `json:"user"`
|
||||
}{"text_mention", v.Text, v.User})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LEAVES: no text field.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RichTextCustomEmoji is a custom emoji leaf with alternative text.
|
||||
type RichTextCustomEmoji struct {
|
||||
CustomEmojiID string
|
||||
AlternativeText string
|
||||
}
|
||||
|
||||
func (RichTextCustomEmoji) isRichText() {}
|
||||
func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
CustomEmojiID string `json:"custom_emoji_id"`
|
||||
AlternativeText string `json:"alternative_text"`
|
||||
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
||||
}
|
||||
|
||||
// RichTextMathematicalExpression is an inline mathematical expression leaf.
|
||||
type RichTextMathematicalExpression struct {
|
||||
Expression string
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
// RichTextAnchor is a named anchor leaf that anchor links can point to.
|
||||
type RichTextAnchor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (RichTextAnchor) isRichText() {}
|
||||
func (v RichTextAnchor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}{"anchor", v.Name})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON -> RichText parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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 RichTextPlain(s), nil
|
||||
}
|
||||
// 2. array
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err == nil {
|
||||
arr := make(RichTextArray, len(raw))
|
||||
for i, it := range raw {
|
||||
rt, err := UnmarshalRichText(it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = rt
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
// 3. object -> dispatch on type, grabbing the raw text along the way
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &head); err != nil {
|
||||
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
|
||||
}
|
||||
|
||||
// Recursively parse the nested text, if any.
|
||||
var inner RichText
|
||||
if len(head.Text) > 0 {
|
||||
var err error
|
||||
if inner, err = UnmarshalRichText(head.Text); err != nil {
|
||||
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
if richTextWrapTags[head.Type] {
|
||||
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||
}
|
||||
|
||||
switch head.Type {
|
||||
case "url":
|
||||
var v struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextURL{inner, v.URL}, nil
|
||||
case "email_address":
|
||||
var v struct {
|
||||
V string `json:"email_address"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextEmailAddress{inner, v.V}, nil
|
||||
case "phone_number":
|
||||
var v struct {
|
||||
V string `json:"phone_number"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextPhoneNumber{inner, v.V}, nil
|
||||
case "bank_card_number":
|
||||
var v struct {
|
||||
V string `json:"bank_card_number"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextBankCardNumber{inner, v.V}, nil
|
||||
case "mention":
|
||||
var v struct {
|
||||
V string `json:"username"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextMention{inner, v.V}, nil
|
||||
case "hashtag":
|
||||
var v struct {
|
||||
V string `json:"hashtag"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextHashtag{inner, v.V}, nil
|
||||
case "cashtag":
|
||||
var v struct {
|
||||
V string `json:"cashtag"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextCashtag{inner, v.V}, nil
|
||||
case "bot_command":
|
||||
var v struct {
|
||||
V string `json:"bot_command"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextBotCommand{inner, v.V}, nil
|
||||
case "anchor_link":
|
||||
var v struct {
|
||||
V string `json:"anchor_name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextAnchorLink{inner, v.V}, nil
|
||||
case "reference":
|
||||
var v struct {
|
||||
V string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextReference{inner, v.V}, nil
|
||||
case "reference_link":
|
||||
var v struct {
|
||||
V string `json:"reference_name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
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 RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
||||
case "text_mention":
|
||||
var v struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextTextMention{inner, v.User}, nil
|
||||
|
||||
// --- 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 RichTextCustomEmoji{v.ID, v.Alt}, nil
|
||||
case "mathematical_expression":
|
||||
var v struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextMathematicalExpression{v.Expression}, nil
|
||||
case "anchor":
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &v)
|
||||
return RichTextAnchor{v.Name}, nil
|
||||
|
||||
default:
|
||||
// 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 RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("richtext: unknown type %q", head.Type)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user