(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:
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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
|
## v1.0.2
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgfmt"
|
||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"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 {
|
func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||||
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// InputPollOptionMedia describes the media to attach to a poll option.
|
||||||
|
// For type "link" set URL instead of Media.
|
||||||
// Since: Bot API 10.0
|
// Since: Bot API 10.0
|
||||||
// See https://core.telegram.org/bots/api#inputpolloptionmedia
|
// See https://core.telegram.org/bots/api#inputpolloptionmedia
|
||||||
type InputPollOptionMedia struct {
|
type InputPollOptionMedia struct {
|
||||||
Type string `json:"type"`
|
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.
|
// 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
|
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.
|
// PollMedia represents media attached to a poll.
|
||||||
// Since: Bot API 10.0
|
// Since: Bot API 10.0
|
||||||
type PollMedia struct {
|
type PollMedia struct {
|
||||||
Animation *Animation `json:"animation,omitempty"`
|
Animation *Animation `json:"animation,omitempty"`
|
||||||
Audio *Audio `json:"audio,omitempty"`
|
Audio *Audio `json:"audio,omitempty"`
|
||||||
Document *Document `json:"document,omitempty"`
|
Document *Document `json:"document,omitempty"`
|
||||||
|
Link *Link `json:"link,omitempty"` // Since: Bot API 10.1
|
||||||
LivePhoto *LivePhoto `json:"live_photo,omitempty"`
|
LivePhoto *LivePhoto `json:"live_photo,omitempty"`
|
||||||
Location *Location `json:"location,omitempty"`
|
Location *Location `json:"location,omitempty"`
|
||||||
Photo []PhotoSize `json:"photo,omitempty"`
|
Photo []PhotoSize `json:"photo,omitempty"`
|
||||||
|
|||||||
@@ -481,6 +481,73 @@ func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params De
|
|||||||
return req.DoWithContext(ctx, api)
|
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.
|
// SetChatPhoto holds parameters for the setChatPhoto method.
|
||||||
// Since: Bot API 3.1
|
// Since: Bot API 3.1
|
||||||
// See https://core.telegram.org/bots/api#setchatphoto
|
// See https://core.telegram.org/bots/api#setchatphoto
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type ChatFullInfo struct {
|
|||||||
|
|
||||||
PersonalChat *Chat `json:"personal_chat,omitempty"`
|
PersonalChat *Chat `json:"personal_chat,omitempty"`
|
||||||
ParentChat *Chat `json:"parent_chat,omitempty"` // Since: Bot API 9.2
|
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"`
|
AvailableReaction []ReactionType `json:"available_reaction,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ type InlineQueryResultsButton struct {
|
|||||||
StartParameter string `json:"start_parameter,omitempty"`
|
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.
|
// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user.
|
||||||
// Since: Bot API 8.0
|
// Since: Bot API 8.0
|
||||||
// See https://core.telegram.org/bots/api#sentwebappmessage
|
// See https://core.telegram.org/bots/api#sentwebappmessage
|
||||||
|
|||||||
@@ -547,10 +547,11 @@ type EditMessageText struct {
|
|||||||
ChatID int64 `json:"chat_id,omitempty"`
|
ChatID int64 `json:"chat_id,omitempty"`
|
||||||
MessageID int `json:"message_id,omitempty"`
|
MessageID int `json:"message_id,omitempty"`
|
||||||
InlineMessageID string `json:"inline_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"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
Entities []MessageEntity `json:"entities,omitempty"`
|
Entities []MessageEntity `json:"entities,omitempty"`
|
||||||
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,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"`
|
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,3 +1090,71 @@ func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params Del
|
|||||||
req := NewRequest[bool]("deleteMessageReaction", params)
|
req := NewRequest[bool]("deleteMessageReaction", params)
|
||||||
return req.DoWithContext(ctx, api)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ type Message struct {
|
|||||||
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1
|
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1
|
||||||
EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4
|
EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4
|
||||||
|
|
||||||
|
RichMessage *RichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1
|
||||||
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
||||||
Audio *Audio `json:"audio,omitempty"`
|
Audio *Audio `json:"audio,omitempty"`
|
||||||
Document *Document `json:"document,omitempty"`
|
Document *Document `json:"document,omitempty"`
|
||||||
@@ -748,3 +749,19 @@ type VideoChatParticipantsInvited struct {
|
|||||||
type SentGuestMessage struct {
|
type SentGuestMessage struct {
|
||||||
InlineMessageID string `json:"inline_message_id"`
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,76 @@
|
|||||||
package richtext
|
package tgapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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 {
|
type RichText interface {
|
||||||
isRichText()
|
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
|
// bold italic underline strikethrough spoiler subscript superscript marked code
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Wrap покрывает все «чистые» оборачивающие узлы одним типом.
|
// RichTextWrap covers all "pure" wrapper nodes with a single type.
|
||||||
type Wrap struct {
|
type RichTextWrap struct {
|
||||||
Tag string // "bold", "italic", ...
|
Tag string // "bold", "italic", ...
|
||||||
Text RichText
|
Text RichText
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Wrap) isRichText() {}
|
func (RichTextWrap) isRichText() {}
|
||||||
|
|
||||||
func (w Wrap) MarshalJSON() ([]byte, error) {
|
func (w RichTextWrap) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
}{w.Tag, w.Text})
|
}{w.Tag, w.Text})
|
||||||
}
|
}
|
||||||
|
|
||||||
var wrapTags = map[string]bool{
|
var richTextWrapTags = map[string]bool{
|
||||||
"bold": true, "italic": true, "underline": true,
|
"bold": true, "italic": true, "underline": true,
|
||||||
"strikethrough": true, "spoiler": true, "subscript": true,
|
"strikethrough": true, "spoiler": true, "subscript": true,
|
||||||
"superscript": true, "marked": true, "code": 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
|
Text RichText
|
||||||
URL string
|
URL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (URL) isRichText() {}
|
func (RichTextURL) isRichText() {}
|
||||||
func (v URL) MarshalJSON() ([]byte, error) {
|
func (v RichTextURL) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -80,13 +78,14 @@ func (v URL) MarshalJSON() ([]byte, error) {
|
|||||||
}{"url", v.Text, v.URL})
|
}{"url", v.Text, v.URL})
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailAddress struct {
|
// RichTextEmailAddress is rich text linking to an email address.
|
||||||
|
type RichTextEmailAddress struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
EmailAddress string
|
EmailAddress string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (EmailAddress) isRichText() {}
|
func (RichTextEmailAddress) isRichText() {}
|
||||||
func (v EmailAddress) MarshalJSON() ([]byte, error) {
|
func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -94,13 +93,14 @@ func (v EmailAddress) MarshalJSON() ([]byte, error) {
|
|||||||
}{"email_address", v.Text, v.EmailAddress})
|
}{"email_address", v.Text, v.EmailAddress})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhoneNumber struct {
|
// RichTextPhoneNumber is rich text linking to a phone number.
|
||||||
|
type RichTextPhoneNumber struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
PhoneNumber string
|
PhoneNumber string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PhoneNumber) isRichText() {}
|
func (RichTextPhoneNumber) isRichText() {}
|
||||||
func (v PhoneNumber) MarshalJSON() ([]byte, error) {
|
func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -108,13 +108,14 @@ func (v PhoneNumber) MarshalJSON() ([]byte, error) {
|
|||||||
}{"phone_number", v.Text, v.PhoneNumber})
|
}{"phone_number", v.Text, v.PhoneNumber})
|
||||||
}
|
}
|
||||||
|
|
||||||
type BankCardNumber struct {
|
// RichTextBankCardNumber is rich text marked as a bank card number.
|
||||||
|
type RichTextBankCardNumber struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
BankCardNumber string
|
BankCardNumber string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (BankCardNumber) isRichText() {}
|
func (RichTextBankCardNumber) isRichText() {}
|
||||||
func (v BankCardNumber) MarshalJSON() ([]byte, error) {
|
func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -122,13 +123,14 @@ func (v BankCardNumber) MarshalJSON() ([]byte, error) {
|
|||||||
}{"bank_card_number", v.Text, v.BankCardNumber})
|
}{"bank_card_number", v.Text, v.BankCardNumber})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mention struct {
|
// RichTextMention is rich text mentioning a user by username.
|
||||||
|
type RichTextMention struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Mention) isRichText() {}
|
func (RichTextMention) isRichText() {}
|
||||||
func (v Mention) MarshalJSON() ([]byte, error) {
|
func (v RichTextMention) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -136,13 +138,14 @@ func (v Mention) MarshalJSON() ([]byte, error) {
|
|||||||
}{"mention", v.Text, v.Username})
|
}{"mention", v.Text, v.Username})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Hashtag struct {
|
// RichTextHashtag is rich text marked as a hashtag.
|
||||||
|
type RichTextHashtag struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
Hashtag string
|
Hashtag string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Hashtag) isRichText() {}
|
func (RichTextHashtag) isRichText() {}
|
||||||
func (v Hashtag) MarshalJSON() ([]byte, error) {
|
func (v RichTextHashtag) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -150,13 +153,14 @@ func (v Hashtag) MarshalJSON() ([]byte, error) {
|
|||||||
}{"hashtag", v.Text, v.Hashtag})
|
}{"hashtag", v.Text, v.Hashtag})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cashtag struct {
|
// RichTextCashtag is rich text marked as a cashtag.
|
||||||
|
type RichTextCashtag struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
Cashtag string
|
Cashtag string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Cashtag) isRichText() {}
|
func (RichTextCashtag) isRichText() {}
|
||||||
func (v Cashtag) MarshalJSON() ([]byte, error) {
|
func (v RichTextCashtag) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -164,13 +168,14 @@ func (v Cashtag) MarshalJSON() ([]byte, error) {
|
|||||||
}{"cashtag", v.Text, v.Cashtag})
|
}{"cashtag", v.Text, v.Cashtag})
|
||||||
}
|
}
|
||||||
|
|
||||||
type BotCommand struct {
|
// RichTextBotCommand is rich text marked as a bot command.
|
||||||
|
type RichTextBotCommand struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
BotCommand string
|
BotCommand string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (BotCommand) isRichText() {}
|
func (RichTextBotCommand) isRichText() {}
|
||||||
func (v BotCommand) MarshalJSON() ([]byte, error) {
|
func (v RichTextBotCommand) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -178,13 +183,14 @@ func (v BotCommand) MarshalJSON() ([]byte, error) {
|
|||||||
}{"bot_command", v.Text, v.BotCommand})
|
}{"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
|
Text RichText
|
||||||
AnchorName string
|
AnchorName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AnchorLink) isRichText() {}
|
func (RichTextAnchorLink) isRichText() {}
|
||||||
func (v AnchorLink) MarshalJSON() ([]byte, error) {
|
func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -192,13 +198,14 @@ func (v AnchorLink) MarshalJSON() ([]byte, error) {
|
|||||||
}{"anchor_link", v.Text, v.AnchorName})
|
}{"anchor_link", v.Text, v.AnchorName})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Reference struct {
|
// RichTextReference is rich text marked as a named reference target.
|
||||||
|
type RichTextReference struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Reference) isRichText() {}
|
func (RichTextReference) isRichText() {}
|
||||||
func (v Reference) MarshalJSON() ([]byte, error) {
|
func (v RichTextReference) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -206,13 +213,14 @@ func (v Reference) MarshalJSON() ([]byte, error) {
|
|||||||
}{"reference", v.Text, v.Name})
|
}{"reference", v.Text, v.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReferenceLink struct {
|
// RichTextReferenceLink is rich text linking to a named reference.
|
||||||
|
type RichTextReferenceLink struct {
|
||||||
Text RichText
|
Text RichText
|
||||||
ReferenceName string
|
ReferenceName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ReferenceLink) isRichText() {}
|
func (RichTextReferenceLink) isRichText() {}
|
||||||
func (v ReferenceLink) MarshalJSON() ([]byte, error) {
|
func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
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
|
Text RichText
|
||||||
UnixTime int64
|
UnixTime int64
|
||||||
DateTimeFormat string
|
DateTimeFormat string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DateTime) isRichText() {}
|
func (RichTextDateTime) isRichText() {}
|
||||||
func (v DateTime) MarshalJSON() ([]byte, error) {
|
func (v RichTextDateTime) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
@@ -240,31 +249,33 @@ func (v DateTime) MarshalJSON() ([]byte, error) {
|
|||||||
}{"date_time", v.Text, v.UnixTime, v.DateTimeFormat})
|
}{"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
|
Text RichText
|
||||||
User tgapi.User
|
User User
|
||||||
}
|
}
|
||||||
|
|
||||||
func (TextMention) isRichText() {}
|
func (RichTextTextMention) isRichText() {}
|
||||||
func (v TextMention) MarshalJSON() ([]byte, error) {
|
func (v RichTextTextMention) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text RichText `json:"text"`
|
Text RichText `json:"text"`
|
||||||
User tgapi.User `json:"user"`
|
User User `json:"user"`
|
||||||
}{"text_mention", v.Text, v.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
|
CustomEmojiID string
|
||||||
AlternativeText string
|
AlternativeText string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (CustomEmoji) isRichText() {}
|
func (RichTextCustomEmoji) isRichText() {}
|
||||||
func (v CustomEmoji) MarshalJSON() ([]byte, error) {
|
func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
CustomEmojiID string `json:"custom_emoji_id"`
|
CustomEmojiID string `json:"custom_emoji_id"`
|
||||||
@@ -272,24 +283,26 @@ func (v CustomEmoji) MarshalJSON() ([]byte, error) {
|
|||||||
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
||||||
}
|
}
|
||||||
|
|
||||||
type MathematicalExpression struct {
|
// RichTextMathematicalExpression is an inline mathematical expression leaf.
|
||||||
|
type RichTextMathematicalExpression struct {
|
||||||
Expression string
|
Expression string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (MathematicalExpression) isRichText() {}
|
func (RichTextMathematicalExpression) isRichText() {}
|
||||||
func (v MathematicalExpression) MarshalJSON() ([]byte, error) {
|
func (v RichTextMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Expression string `json:"expression"`
|
Expression string `json:"expression"`
|
||||||
}{"mathematical_expression", v.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
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Anchor) isRichText() {}
|
func (RichTextAnchor) isRichText() {}
|
||||||
func (v Anchor) MarshalJSON() ([]byte, error) {
|
func (v RichTextAnchor) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
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) {
|
// UnmarshalRichText parses a RichText tree from JSON: a string, an array, or
|
||||||
// 1. строка
|
// 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
|
var s string
|
||||||
if err := json.Unmarshal(data, &s); err == nil {
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
return String(s), nil
|
return RichTextPlain(s), nil
|
||||||
}
|
}
|
||||||
// 2. массив
|
// 2. array
|
||||||
var raw []json.RawMessage
|
var raw []json.RawMessage
|
||||||
if err := json.Unmarshal(data, &raw); err == nil {
|
if err := json.Unmarshal(data, &raw); err == nil {
|
||||||
arr := make(Array, len(raw))
|
arr := make(RichTextArray, len(raw))
|
||||||
for i, it := range raw {
|
for i, it := range raw {
|
||||||
rt, err := Unmarshal(it)
|
rt, err := UnmarshalRichText(it)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -319,7 +335,7 @@ func Unmarshal(data []byte) (RichText, error) {
|
|||||||
}
|
}
|
||||||
return arr, nil
|
return arr, nil
|
||||||
}
|
}
|
||||||
// 3. объект -> смотрим type, попутно вытаскиваем сырой text
|
// 3. object -> dispatch on type, grabbing the raw text along the way
|
||||||
var head struct {
|
var head struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text json.RawMessage `json:"text"`
|
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)
|
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рекурсивно разбираем вложенный text, если он есть.
|
// Recursively parse the nested text, if any.
|
||||||
var inner RichText
|
var inner RichText
|
||||||
if len(head.Text) > 0 {
|
if len(head.Text) > 0 {
|
||||||
var err error
|
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)
|
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if wrapTags[head.Type] {
|
if richTextWrapTags[head.Type] {
|
||||||
return Wrap{Tag: head.Type, Text: inner}, nil
|
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch head.Type {
|
switch head.Type {
|
||||||
@@ -349,107 +365,107 @@ func Unmarshal(data []byte) (RichText, error) {
|
|||||||
if err := json.Unmarshal(data, &v); err != nil {
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return URL{inner, v.URL}, nil
|
return RichTextURL{inner, v.URL}, nil
|
||||||
case "email_address":
|
case "email_address":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"email_address"`
|
V string `json:"email_address"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return EmailAddress{inner, v.V}, nil
|
return RichTextEmailAddress{inner, v.V}, nil
|
||||||
case "phone_number":
|
case "phone_number":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"phone_number"`
|
V string `json:"phone_number"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return PhoneNumber{inner, v.V}, nil
|
return RichTextPhoneNumber{inner, v.V}, nil
|
||||||
case "bank_card_number":
|
case "bank_card_number":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"bank_card_number"`
|
V string `json:"bank_card_number"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return BankCardNumber{inner, v.V}, nil
|
return RichTextBankCardNumber{inner, v.V}, nil
|
||||||
case "mention":
|
case "mention":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"username"`
|
V string `json:"username"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return Mention{inner, v.V}, nil
|
return RichTextMention{inner, v.V}, nil
|
||||||
case "hashtag":
|
case "hashtag":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"hashtag"`
|
V string `json:"hashtag"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return Hashtag{inner, v.V}, nil
|
return RichTextHashtag{inner, v.V}, nil
|
||||||
case "cashtag":
|
case "cashtag":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"cashtag"`
|
V string `json:"cashtag"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return Cashtag{inner, v.V}, nil
|
return RichTextCashtag{inner, v.V}, nil
|
||||||
case "bot_command":
|
case "bot_command":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"bot_command"`
|
V string `json:"bot_command"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return BotCommand{inner, v.V}, nil
|
return RichTextBotCommand{inner, v.V}, nil
|
||||||
case "anchor_link":
|
case "anchor_link":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"anchor_name"`
|
V string `json:"anchor_name"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return AnchorLink{inner, v.V}, nil
|
return RichTextAnchorLink{inner, v.V}, nil
|
||||||
case "reference":
|
case "reference":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"name"`
|
V string `json:"name"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return Reference{inner, v.V}, nil
|
return RichTextReference{inner, v.V}, nil
|
||||||
case "reference_link":
|
case "reference_link":
|
||||||
var v struct {
|
var v struct {
|
||||||
V string `json:"reference_name"`
|
V string `json:"reference_name"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return ReferenceLink{inner, v.V}, nil
|
return RichTextReferenceLink{inner, v.V}, nil
|
||||||
case "date_time":
|
case "date_time":
|
||||||
var v struct {
|
var v struct {
|
||||||
UnixTime int64 `json:"unix_time"`
|
UnixTime int64 `json:"unix_time"`
|
||||||
DateTimeFormat string `json:"date_time_format"`
|
DateTimeFormat string `json:"date_time_format"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return DateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
||||||
case "text_mention":
|
case "text_mention":
|
||||||
var v struct {
|
var v struct {
|
||||||
User tgapi.User `json:"user"`
|
User User `json:"user"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return TextMention{inner, v.User}, nil
|
return RichTextTextMention{inner, v.User}, nil
|
||||||
|
|
||||||
// --- листья без text ---
|
// --- leaves without text ---
|
||||||
case "custom_emoji":
|
case "custom_emoji":
|
||||||
var v struct {
|
var v struct {
|
||||||
ID string `json:"custom_emoji_id"`
|
ID string `json:"custom_emoji_id"`
|
||||||
Alt string `json:"alternative_text"`
|
Alt string `json:"alternative_text"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return CustomEmoji{v.ID, v.Alt}, nil
|
return RichTextCustomEmoji{v.ID, v.Alt}, nil
|
||||||
case "mathematical_expression":
|
case "mathematical_expression":
|
||||||
var v struct {
|
var v struct {
|
||||||
Expression string `json:"expression"`
|
Expression string `json:"expression"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return MathematicalExpression{v.Expression}, nil
|
return RichTextMathematicalExpression{v.Expression}, nil
|
||||||
case "anchor":
|
case "anchor":
|
||||||
var v struct {
|
var v struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
return Anchor{v.Name}, nil
|
return RichTextAnchor{v.Name}, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// forward-compat: неизвестный тег с полем text сохраняем как Wrap,
|
// forward-compat: keep an unknown tag with a text field as
|
||||||
// без text — как ошибку (нельзя угадать форму).
|
// RichTextWrap; without text it is an error (the shape cannot be guessed).
|
||||||
if inner != nil {
|
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)
|
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"`
|
Date int64 `json:"date"`
|
||||||
Bio *string `json:"bio,omitempty"`
|
Bio *string `json:"bio,omitempty"`
|
||||||
InviteLink *ChatInviteLink `json:"invite_link,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.
|
// 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
|
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
|
CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6
|
||||||
SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0
|
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.
|
// UserProfilePhotos represents a user's profile photos.
|
||||||
|
|||||||
+492
@@ -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 "<cite>" + credit + "</cite>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <b>.
|
||||||
|
func (r Rich) Bold() Rich { return "<b>" + r + "</b>" }
|
||||||
|
|
||||||
|
// Italic wraps the fragment in <i>.
|
||||||
|
func (r Rich) Italic() Rich { return "<i>" + r + "</i>" }
|
||||||
|
|
||||||
|
// Underline wraps the fragment in <u>.
|
||||||
|
func (r Rich) Underline() Rich { return "<u>" + r + "</u>" }
|
||||||
|
|
||||||
|
// Strike wraps the fragment in <s>.
|
||||||
|
func (r Rich) Strike() Rich { return "<s>" + r + "</s>" }
|
||||||
|
|
||||||
|
// Code wraps the fragment in <code>.
|
||||||
|
func (r Rich) Code() Rich { return "<code>" + r + "</code>" }
|
||||||
|
|
||||||
|
// Mark wraps the fragment in <mark>.
|
||||||
|
func (r Rich) Mark() Rich { return "<mark>" + r + "</mark>" }
|
||||||
|
|
||||||
|
// Sub wraps the fragment in <sub>.
|
||||||
|
func (r Rich) Sub() Rich { return "<sub>" + r + "</sub>" }
|
||||||
|
|
||||||
|
// Sup wraps the fragment in <sup>.
|
||||||
|
func (r Rich) Sup() Rich { return "<sup>" + r + "</sup>" }
|
||||||
|
|
||||||
|
// Spoiler wraps the fragment in <tg-spoiler>.
|
||||||
|
func (r Rich) Spoiler() Rich { return "<tg-spoiler>" + r + "</tg-spoiler>" }
|
||||||
|
|
||||||
|
// Link wraps the fragment in a hyperlink to url.
|
||||||
|
func (r Rich) Link(url string) Rich { return `<a href="` + escapeRich(url) + `">` + r + "</a>" }
|
||||||
|
|
||||||
|
// 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 (<a name>).
|
||||||
|
func (r Rich) Anchor(name string) Rich { return `<a name="` + escapeRich(name) + `">` + r + "</a>" }
|
||||||
|
|
||||||
|
// 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 <tg-reference> to the named reference.
|
||||||
|
func (r Rich) Ref(ref string) Rich {
|
||||||
|
return `<tg-reference name="` + escapeRich(ref) + `">` + r + "</tg-reference>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji builds a custom emoji fragment with alt text as fallback.
|
||||||
|
func Emoji(emojiID, alt string) Rich {
|
||||||
|
return `<tg-emoji emoji-id="` + escapeRich(emojiID) + `">` + escapeRich(alt) + `</tg-emoji>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time marks the fragment as a <tg-time> bound to t.
|
||||||
|
func (r Rich) Time(t time.Time) Rich {
|
||||||
|
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `">` + r + "</tg-time>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeFormat marks the fragment as a <tg-time> with an explicit display format.
|
||||||
|
func (r Rich) TimeFormat(t time.Time, format string) Rich {
|
||||||
|
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `" format="` + escapeRich(format) + `">` + r + "</tg-time>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math wraps the fragment in an inline <tg-math> expression.
|
||||||
|
func (r Rich) Math() Rich { return `<tg-math>` + r + `</tg-math>` }
|
||||||
|
|
||||||
|
// Br returns a line break fragment.
|
||||||
|
func Br() Rich { return "<br>" }
|
||||||
|
|
||||||
|
// H1 builds a level-1 heading block.
|
||||||
|
func H1(items ...Rich) RichBlock { return RichBlock("<h1>" + richJoin(items...) + "</h1>") }
|
||||||
|
|
||||||
|
// H2 builds a level-2 heading block.
|
||||||
|
func H2(items ...Rich) RichBlock { return RichBlock("<h2>" + richJoin(items...) + "</h2>") }
|
||||||
|
|
||||||
|
// H3 builds a level-3 heading block.
|
||||||
|
func H3(items ...Rich) RichBlock { return RichBlock("<h3>" + richJoin(items...) + "</h3>") }
|
||||||
|
|
||||||
|
// H4 builds a level-4 heading block.
|
||||||
|
func H4(items ...Rich) RichBlock { return RichBlock("<h4>" + richJoin(items...) + "</h4>") }
|
||||||
|
|
||||||
|
// H5 builds a level-5 heading block.
|
||||||
|
func H5(items ...Rich) RichBlock { return RichBlock("<h5>" + richJoin(items...) + "</h5>") }
|
||||||
|
|
||||||
|
// H6 builds a level-6 heading block.
|
||||||
|
func H6(items ...Rich) RichBlock { return RichBlock("<h6>" + richJoin(items...) + "</h6>") }
|
||||||
|
|
||||||
|
// P builds a paragraph block.
|
||||||
|
func P(items ...Rich) RichBlock { return RichBlock("<p>" + richJoin(items...) + "</p>") }
|
||||||
|
|
||||||
|
// Pre builds a preformatted code block.
|
||||||
|
func Pre(items ...Rich) RichBlock { return RichBlock("<pre>" + richJoin(items...) + "</pre>") }
|
||||||
|
|
||||||
|
// PreCode builds a preformatted code block tagged with a language.
|
||||||
|
func PreCode(lang string, items ...Rich) RichBlock {
|
||||||
|
return RichBlock(`<pre><code class="language-` + escapeRich(lang) + `">` + richJoin(items...) + `</code></pre>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer builds a footer block.
|
||||||
|
func Footer(items ...Rich) RichBlock { return RichBlock("<footer>" + richJoin(items...) + "</footer>") }
|
||||||
|
|
||||||
|
// Hr builds a divider block.
|
||||||
|
func Hr() RichBlock { return "<hr/>" }
|
||||||
|
|
||||||
|
// AnchorBlock builds a standalone named anchor between blocks: <a name></a>.
|
||||||
|
func AnchorBlock(name string) RichBlock {
|
||||||
|
return RichBlock(`<a name="` + escapeHTML(name) + `"></a>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <li value>).
|
||||||
|
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(`<input type="checkbox">`)
|
||||||
|
if l.checked {
|
||||||
|
input = `<input type="checkbox" checked>`
|
||||||
|
}
|
||||||
|
return "<li>" + input + l.text + "</li>"
|
||||||
|
}
|
||||||
|
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 + "</li>"
|
||||||
|
}
|
||||||
|
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(`<ul>` + joinLiItems(items) + `</ul>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OlOpts holds the <ol> 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)) + "</ol>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote builds a block quotation: lines are joined with <br> (as in the
|
||||||
|
// official HTML example) and credit renders as a trailing <cite>.
|
||||||
|
func Blockquote(credit Rich, lines ...Rich) RichBlock {
|
||||||
|
return RichBlock(`<blockquote>` + richJoinSep(Br(), lines...) + cite(credit) + `</blockquote>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aside builds a pull quote (<aside>) with an optional <cite> credit.
|
||||||
|
func Aside(credit Rich, lines ...Rich) RichBlock {
|
||||||
|
return RichBlock(`<aside>` + richJoinSep(Br(), lines...) + cite(credit) + `</aside>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichMedia is a media element for standalone blocks, collages, and
|
||||||
|
// slideshows. Media is sent by HTTP(S) URL only; file_id does not work in
|
||||||
|
// html mode.
|
||||||
|
type RichMedia struct {
|
||||||
|
tag string
|
||||||
|
src string
|
||||||
|
spoiler bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo builds a photo element from an HTTP(S) URL.
|
||||||
|
func Photo(url string) RichMedia { return RichMedia{tag: "img", src: url} }
|
||||||
|
|
||||||
|
// Video builds a video or animation element from an HTTP(S) URL; the server
|
||||||
|
// distinguishes them by the URL extension.
|
||||||
|
func Video(url string) RichMedia { return RichMedia{tag: "video", src: url} }
|
||||||
|
|
||||||
|
// Audio builds an audio or voice-note element from an HTTP(S) URL; the
|
||||||
|
// server treats .ogg as a voice note.
|
||||||
|
func Audio(url string) RichMedia { return RichMedia{tag: "audio", src: url} }
|
||||||
|
|
||||||
|
// SetSpoiler hides the media behind a spoiler overlay.
|
||||||
|
func (m RichMedia) SetSpoiler() RichMedia {
|
||||||
|
m.spoiler = true
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func (m RichMedia) build() string {
|
||||||
|
attrs := []string{`src="` + escapeHTML(m.src) + `"`}
|
||||||
|
if m.spoiler {
|
||||||
|
attrs = append(attrs, "tg-spoiler")
|
||||||
|
}
|
||||||
|
if m.tag == "img" {
|
||||||
|
return "<img " + strings.Join(attrs, " ") + "/>"
|
||||||
|
}
|
||||||
|
return openTag(m.tag, attrs) + "</" + m.tag + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block turns the media into a standalone block without a caption.
|
||||||
|
func (m RichMedia) Block() RichBlock { return RichBlock(m.build()) }
|
||||||
|
|
||||||
|
// Caption wraps the media in <figure> with a caption and optional credit.
|
||||||
|
func (m RichMedia) Caption(credit Rich, caption ...Rich) RichBlock {
|
||||||
|
return RichBlock(`<figure>` + m.build() + string(figcaption(credit, caption...)) + `</figure>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func figcaption(credit Rich, caption ...Rich) Rich {
|
||||||
|
text := richJoin(caption...) + cite(credit)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<figcaption>" + text + "</figcaption>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func richMediaJoin(items []RichMedia) string {
|
||||||
|
var out string
|
||||||
|
for _, item := range items {
|
||||||
|
out += item.build()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map builds a location map block.
|
||||||
|
func Map(lat, long float64, zoom int) RichBlock {
|
||||||
|
latString := fmt.Sprintf("%.6f", lat)
|
||||||
|
longString := fmt.Sprintf("%.6f", long)
|
||||||
|
zoomString := strconv.Itoa(zoom)
|
||||||
|
return RichBlock(`<tg-map lat="` + latString + `" long="` + longString + `" zoom="` + zoomString + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapCaption builds a map block wrapped in <figure> with a caption.
|
||||||
|
func MapCaption(lat, long float64, zoom int, credit Rich, caption ...Rich) RichBlock {
|
||||||
|
return `<figure>` + Map(lat, long, zoom) + RichBlock(figcaption(credit, caption...)) + `</figure>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collage builds a media collage block.
|
||||||
|
func Collage(items ...RichMedia) RichBlock {
|
||||||
|
return RichBlock(`<tg-collage>` + richMediaJoin(items) + `</tg-collage>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollageCaption builds a media collage block with a caption.
|
||||||
|
func CollageCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
|
||||||
|
return RichBlock(`<tg-collage>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-collage>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slideshow builds a media slideshow block.
|
||||||
|
func Slideshow(items ...RichMedia) RichBlock {
|
||||||
|
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + `</tg-slideshow>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlideshowCaption builds a media slideshow block with a caption.
|
||||||
|
func SlideshowCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
|
||||||
|
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-slideshow>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichCell is a table cell under construction for Row.
|
||||||
|
type RichCell struct {
|
||||||
|
text Rich
|
||||||
|
colspan int
|
||||||
|
rowspan int
|
||||||
|
align string
|
||||||
|
valign string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell builds a table cell from inline fragments.
|
||||||
|
func Cell(items ...Rich) RichCell {
|
||||||
|
return RichCell{text: richJoin(items...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpan sets colspan and rowspan; zero leaves the attribute out.
|
||||||
|
func (r RichCell) SetSpan(col, row int) RichCell {
|
||||||
|
r.colspan = col
|
||||||
|
r.rowspan = row
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlign sets horizontal alignment: "left", "center", or "right".
|
||||||
|
func (r RichCell) SetAlign(align string) RichCell {
|
||||||
|
r.align = align
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVAlign sets vertical alignment: "top", "middle", or "bottom".
|
||||||
|
func (r RichCell) SetVAlign(align string) RichCell {
|
||||||
|
r.valign = align
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
func (r RichCell) build(isHeader bool) string {
|
||||||
|
attrs := make([]string, 0, 4)
|
||||||
|
if r.colspan > 0 {
|
||||||
|
attrs = append(attrs, `colspan="`+strconv.Itoa(r.colspan)+`"`)
|
||||||
|
}
|
||||||
|
if r.rowspan > 0 {
|
||||||
|
attrs = append(attrs, `rowspan="`+strconv.Itoa(r.rowspan)+`"`)
|
||||||
|
}
|
||||||
|
if r.align != "" {
|
||||||
|
attrs = append(attrs, `align="`+escapeHTML(r.align)+`"`)
|
||||||
|
}
|
||||||
|
if r.valign != "" {
|
||||||
|
attrs = append(attrs, `valign="`+escapeHTML(r.valign)+`"`)
|
||||||
|
}
|
||||||
|
tag := "td"
|
||||||
|
if isHeader {
|
||||||
|
tag = "th"
|
||||||
|
}
|
||||||
|
return openTag(tag, attrs) + string(r.text) + "</" + tag + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichRow is a table row under construction for Table.
|
||||||
|
type RichRow struct {
|
||||||
|
cells []RichCell
|
||||||
|
isHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row builds a table row; isHeader renders every cell as <th>.
|
||||||
|
func Row(isHeader bool, cells ...RichCell) RichRow {
|
||||||
|
return RichRow{cells: cells, isHeader: isHeader}
|
||||||
|
}
|
||||||
|
func (r RichRow) build() string {
|
||||||
|
var buildCells string
|
||||||
|
for _, cell := range r.cells {
|
||||||
|
buildCells += cell.build(r.isHeader)
|
||||||
|
}
|
||||||
|
return `<tr>` + buildCells + `</tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table builds a table block; an empty caption is omitted.
|
||||||
|
func Table(bordered, striped bool, caption Rich, rows ...RichRow) RichBlock {
|
||||||
|
attrs := make([]string, 0, 2)
|
||||||
|
if bordered {
|
||||||
|
attrs = append(attrs, "bordered")
|
||||||
|
}
|
||||||
|
if striped {
|
||||||
|
attrs = append(attrs, "striped")
|
||||||
|
}
|
||||||
|
out := openTag("table", attrs)
|
||||||
|
if caption != "" {
|
||||||
|
out += `<caption>` + string(caption) + `</caption>`
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
out += row.build()
|
||||||
|
}
|
||||||
|
out += `</table>`
|
||||||
|
return RichBlock(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details builds an expandable block with an inline summary.
|
||||||
|
func Details(isOpen bool, summary Rich, blocks ...RichBlock) RichBlock {
|
||||||
|
tag := `<details>`
|
||||||
|
if isOpen {
|
||||||
|
tag = `<details open>`
|
||||||
|
}
|
||||||
|
return RichBlock(tag) + `<summary>` + RichBlock(summary) + `</summary>` + richBlocksJoin(blocks...) + `</details>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathBlock builds a block-level mathematical expression.
|
||||||
|
func MathBlock(expr string) RichBlock {
|
||||||
|
return RichBlock(`<tg-math-block>` + escapeHTML(expr) + `</tg-math-block>`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package tgfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRichInline(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got Rich
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"bold", NewRich("bold text").Bold(), "<b>bold text</b>"},
|
||||||
|
{"spoiler", NewRich("spoiler").Spoiler(), "<tg-spoiler>spoiler</tg-spoiler>"},
|
||||||
|
{"escape", NewRich(`a<b> & "c"`), "a<b> & "c""},
|
||||||
|
{"link", NewRich("inline URL").Link("https://t.me/"), `<a href="https://t.me/">inline URL</a>`},
|
||||||
|
{"link attr escape", NewRich("x").Link(`https://e/?q="><b>`), `<a href="https://e/?q="><b>">x</a>`},
|
||||||
|
{"mention", NewRich("user").Mention(123456789), `<a href="tg://user?id=123456789">user</a>`},
|
||||||
|
{"anchor", Rich("").Anchor("chapter-1"), `<a name="chapter-1"></a>`},
|
||||||
|
{"anchor link", NewRich("in-document link").AnchorLink("chapter-1"), `<a href="#chapter-1">in-document link</a>`},
|
||||||
|
{"reference", NewRich("Referenced text").Ref("note-1"), `<tg-reference name="note-1">Referenced text</tg-reference>`},
|
||||||
|
{"emoji", Emoji("5368324170671202286", "👍"), `<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>`},
|
||||||
|
{"emoji alt escape", Emoji("1", `<x>`), `<tg-emoji emoji-id="1"><x></tg-emoji>`},
|
||||||
|
{"time format", NewRich("22:45 tomorrow").TimeFormat(time.Unix(1647531900, 0), "wDT"),
|
||||||
|
`<tg-time unix="1647531900" format="wDT">22:45 tomorrow</tg-time>`},
|
||||||
|
{"math", NewRich("x^2 + y^2").Math(), "<tg-math>x^2 + y^2</tg-math>"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if string(c.got) != c.want {
|
||||||
|
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlocks(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got RichBlock
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"h1", H1(NewRich("Heading 1")), "<h1>Heading 1</h1>"},
|
||||||
|
{"p", P(NewRich("Paragraph text")), "<p>Paragraph text</p>"},
|
||||||
|
{"pre code", PreCode("python", NewRich("print('x')")),
|
||||||
|
`<pre><code class="language-python">print('x')</code></pre>`},
|
||||||
|
{"footer", Footer(NewRich("Footer text")), "<footer>Footer text</footer>"},
|
||||||
|
{"hr", Hr(), "<hr/>"},
|
||||||
|
{"anchor block", AnchorBlock("chapter-2"), `<a name="chapter-2"></a>`},
|
||||||
|
{"math block", MathBlock("E = mc^2"), "<tg-math-block>E = mc^2</tg-math-block>"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if string(c.got) != c.want {
|
||||||
|
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichLists(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got RichBlock
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ul", Ul(Li(NewRich("unordered list item"))),
|
||||||
|
"<ul><li>unordered list item</li></ul>"},
|
||||||
|
{"ol plain", Ol(OlOpts{}, Li(NewRich("ordered list item"))),
|
||||||
|
"<ol><li>ordered list item</li></ol>"},
|
||||||
|
{"ol attrs", Ol(OlOpts{Start: 3, Type: "a", Reversed: true}, Li(NewRich("ordered list item"))),
|
||||||
|
`<ol start="3" type="a" reversed><li>ordered list item</li></ol>`},
|
||||||
|
{"li value type", Ol(OlOpts{}, Li(NewRich("item")).SetValue(7).SetType("i")),
|
||||||
|
`<ol><li value="7" type="i">item</li></ol>`},
|
||||||
|
{"checkboxes", Ul(
|
||||||
|
LiCheckbox(true, NewRich("Checked checkbox")),
|
||||||
|
LiCheckbox(false, NewRich("Unchecked checkbox")),
|
||||||
|
), `<ul><li><input type="checkbox" checked>Checked checkbox</li><li><input type="checkbox">Unchecked checkbox</li></ul>`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if string(c.got) != c.want {
|
||||||
|
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichQuotes(t *testing.T) {
|
||||||
|
got := Blockquote(NewRich("The Author"),
|
||||||
|
NewRich("Block quotation started"),
|
||||||
|
NewRich("Block quotation continued"),
|
||||||
|
NewRich("The last line of the block quotation"),
|
||||||
|
)
|
||||||
|
want := "<blockquote>Block quotation started<br>Block quotation continued<br>" +
|
||||||
|
"The last line of the block quotation<cite>The Author</cite></blockquote>"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("blockquote:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = Aside(NewRich("The Author"), NewRich("Pull quote"))
|
||||||
|
want = "<aside>Pull quote<cite>The Author</cite></aside>"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("aside:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = Blockquote("", NewRich("no credit"))
|
||||||
|
want = "<blockquote>no credit</blockquote>"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("blockquote without credit:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichMedia(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got RichBlock
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"photo", Photo("https://telegram.org/example/photo.jpg").Block(),
|
||||||
|
`<img src="https://telegram.org/example/photo.jpg"/>`},
|
||||||
|
{"video", Video("https://telegram.org/example/video.mp4").Block(),
|
||||||
|
`<video src="https://telegram.org/example/video.mp4"></video>`},
|
||||||
|
{"audio", Audio("https://telegram.org/example/audio.mp3").Block(),
|
||||||
|
`<audio src="https://telegram.org/example/audio.mp3"></audio>`},
|
||||||
|
{"photo spoiler caption", Photo("https://telegram.org/example/photo.jpg").SetSpoiler().
|
||||||
|
Caption(NewRich("Photo credit"), NewRich("Photo caption")),
|
||||||
|
`<figure><img src="https://telegram.org/example/photo.jpg" tg-spoiler/>` +
|
||||||
|
`<figcaption>Photo caption<cite>Photo credit</cite></figcaption></figure>`},
|
||||||
|
{"video caption no credit", Video("https://telegram.org/example/video.mp4").
|
||||||
|
Caption("", NewRich("Video caption")),
|
||||||
|
`<figure><video src="https://telegram.org/example/video.mp4"></video>` +
|
||||||
|
`<figcaption>Video caption</figcaption></figure>`},
|
||||||
|
{"map", Map(41.9, 12.5, 14), `<tg-map lat="41.900000" long="12.500000" zoom="14"/>`},
|
||||||
|
{"map caption", MapCaption(41.9, 12.5, 14, "", NewRich("Map caption")),
|
||||||
|
`<figure><tg-map lat="41.900000" long="12.500000" zoom="14"/><figcaption>Map caption</figcaption></figure>`},
|
||||||
|
{"collage", Collage(
|
||||||
|
Photo("https://telegram.org/example/photo.jpg"),
|
||||||
|
Video("https://telegram.org/example/video.mp4"),
|
||||||
|
), `<tg-collage><img src="https://telegram.org/example/photo.jpg"/>` +
|
||||||
|
`<video src="https://telegram.org/example/video.mp4"></video></tg-collage>`},
|
||||||
|
{"slideshow caption", SlideshowCaption("", NewRich("Slideshow caption"),
|
||||||
|
Photo("https://telegram.org/example/photo.jpg"),
|
||||||
|
), `<tg-slideshow><img src="https://telegram.org/example/photo.jpg"/>` +
|
||||||
|
`<figcaption>Slideshow caption</figcaption></tg-slideshow>`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if string(c.got) != c.want {
|
||||||
|
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichTable(t *testing.T) {
|
||||||
|
got := Table(false, false, "",
|
||||||
|
Row(true, Cell(NewRich("Header 1")), Cell(NewRich("Header 2"))),
|
||||||
|
Row(false, Cell(NewRich("Value 1")), Cell(NewRich("Value 2"))),
|
||||||
|
)
|
||||||
|
want := "<table><tr><th>Header 1</th><th>Header 2</th></tr>" +
|
||||||
|
"<tr><td>Value 1</td><td>Value 2</td></tr></table>"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("plain table:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = Table(true, true, NewRich("Table caption"),
|
||||||
|
Row(false,
|
||||||
|
Cell(NewRich("Value")).SetSpan(2, 2).SetAlign("left"),
|
||||||
|
Cell(NewRich("Value2")).SetAlign("center"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
want = `<table bordered striped><caption>Table caption</caption>` +
|
||||||
|
`<tr><td colspan="2" rowspan="2" align="left">Value</td><td align="center">Value2</td></tr></table>`
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("table attrs:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichDetails(t *testing.T) {
|
||||||
|
got := Details(true, NewRich("Title"), P(NewRich("Content")))
|
||||||
|
want := "<details open><summary>Title</summary><p>Content</p></details>"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("details:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
package richtext
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RichBlock — блок в структурированном rich-сообщении.
|
|
||||||
type RichBlock interface {
|
|
||||||
isRichBlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RichMessage — корневой тип структурированного сообщения (Bot API 10.1).
|
|
||||||
type RichMessage struct {
|
|
||||||
Blocks []RichBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m RichMessage) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Blocks []RichBlock `json:"blocks"`
|
|
||||||
}{m.Blocks})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *RichMessage) UnmarshalJSON(data []byte) error {
|
|
||||||
msg, err := UnmarshalMessage(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*m = msg
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Вспомогательные типы
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// RichBlockListItem — один элемент списка (ordered/unordered).
|
|
||||||
type RichBlockListItem struct {
|
|
||||||
Blocks []RichBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i RichBlockListItem) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Blocks []RichBlock `json:"blocks"`
|
|
||||||
}{i.Blocks})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *RichBlockListItem) UnmarshalJSON(data []byte) error {
|
|
||||||
item, err := unmarshalListItem(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*i = item
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RichBlockTableCell — ячейка таблицы.
|
|
||||||
type RichBlockTableCell struct {
|
|
||||||
Content []RichBlock
|
|
||||||
ColumnSpan int
|
|
||||||
RowSpan int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c RichBlockTableCell) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Content []RichBlock `json:"content"`
|
|
||||||
ColumnSpan int `json:"column_span,omitempty"`
|
|
||||||
RowSpan int `json:"row_span,omitempty"`
|
|
||||||
}{c.Content, c.ColumnSpan, c.RowSpan})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error {
|
|
||||||
cell, err := unmarshalTableCell(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*c = cell
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// BlockWrap: чистые текстовые блоки — paragraph, section_heading, footer, thinking.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// BlockWrap покрывает все блоки, у которых есть только поле text.
|
|
||||||
type BlockWrap struct {
|
|
||||||
Tag string
|
|
||||||
Text RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockWrap) isRichBlock() {}
|
|
||||||
|
|
||||||
func (b BlockWrap) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text RichText `json:"text"`
|
|
||||||
}{b.Tag, b.Text})
|
|
||||||
}
|
|
||||||
|
|
||||||
var blockWrapTags = map[string]bool{
|
|
||||||
"paragraph": true, "section_heading": true,
|
|
||||||
"footer": true, "thinking": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func Paragraph(t RichText) BlockWrap { return BlockWrap{"paragraph", t} }
|
|
||||||
func SectionHeading(t RichText) BlockWrap { return BlockWrap{"section_heading", t} }
|
|
||||||
func Footer(t RichText) BlockWrap { return BlockWrap{"footer", t} }
|
|
||||||
func Thinking(t RichText) BlockWrap { return BlockWrap{"thinking", t} }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Блок с text + language
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockPreformatted struct {
|
|
||||||
Text RichText
|
|
||||||
Language string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockPreformatted) isRichBlock() {}
|
|
||||||
func (b BlockPreformatted) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text RichText `json:"text"`
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
}{"preformatted", b.Text, b.Language})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Блоки с text + caption
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockBlockQuotation struct {
|
|
||||||
Text RichText
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockBlockQuotation) isRichBlock() {}
|
|
||||||
func (b BlockBlockQuotation) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text RichText `json:"text"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"block_quotation", b.Text, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockPullQuotation struct {
|
|
||||||
Text RichText
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockPullQuotation) isRichBlock() {}
|
|
||||||
func (b BlockPullQuotation) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text RichText `json:"text"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"pull_quotation", b.Text, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Список
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockList struct {
|
|
||||||
Items []RichBlockListItem
|
|
||||||
Ordered bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockList) isRichBlock() {}
|
|
||||||
func (b BlockList) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Items []RichBlockListItem `json:"items"`
|
|
||||||
Ordered bool `json:"ordered"`
|
|
||||||
}{"list", b.Items, b.Ordered})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Контейнеры с items []RichBlock + caption
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockCollage struct {
|
|
||||||
Items []RichBlock
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockCollage) isRichBlock() {}
|
|
||||||
func (b BlockCollage) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Items []RichBlock `json:"items"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"collage", b.Items, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockSlideshow struct {
|
|
||||||
Items []RichBlock
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockSlideshow) isRichBlock() {}
|
|
||||||
func (b BlockSlideshow) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Items []RichBlock `json:"items"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"slideshow", b.Items, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Details — раскрывающийся блок
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockDetails struct {
|
|
||||||
Title RichText
|
|
||||||
Blocks []RichBlock
|
|
||||||
Open bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockDetails) isRichBlock() {}
|
|
||||||
func (b BlockDetails) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title RichText `json:"title"`
|
|
||||||
Blocks []RichBlock `json:"blocks"`
|
|
||||||
Open bool `json:"open"`
|
|
||||||
}{"details", b.Title, b.Blocks, b.Open})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Таблица
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockTable struct {
|
|
||||||
Title RichText
|
|
||||||
Rows [][]RichBlockTableCell
|
|
||||||
Bordered bool
|
|
||||||
Striped bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockTable) isRichBlock() {}
|
|
||||||
func (b BlockTable) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title RichText `json:"title,omitempty"`
|
|
||||||
Rows [][]RichBlockTableCell `json:"rows"`
|
|
||||||
Bordered bool `json:"bordered"`
|
|
||||||
Striped bool `json:"striped"`
|
|
||||||
}{"table", b.Title, b.Rows, b.Bordered, b.Striped})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Карта
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockMap struct {
|
|
||||||
Latitude float64
|
|
||||||
Longitude float64
|
|
||||||
Zoom int
|
|
||||||
Width int
|
|
||||||
Height int
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockMap) isRichBlock() {}
|
|
||||||
func (b BlockMap) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Zoom int `json:"zoom"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"map", b.Latitude, b.Longitude, b.Zoom, b.Width, b.Height, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Медиа-блоки (file_id + caption)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockPhoto struct {
|
|
||||||
FileID string
|
|
||||||
Caption RichText
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockPhoto) isRichBlock() {}
|
|
||||||
func (b BlockPhoto) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
}{"photo", b.FileID, b.Caption, b.URL})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockVideo struct {
|
|
||||||
FileID string
|
|
||||||
Caption RichText
|
|
||||||
Autoplay bool
|
|
||||||
Loop bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockVideo) isRichBlock() {}
|
|
||||||
func (b BlockVideo) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
Autoplay bool `json:"autoplay"`
|
|
||||||
Loop bool `json:"loop"`
|
|
||||||
}{"video", b.FileID, b.Caption, b.Autoplay, b.Loop})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockAudio struct {
|
|
||||||
FileID string
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockAudio) isRichBlock() {}
|
|
||||||
func (b BlockAudio) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"audio", b.FileID, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockAnimation struct {
|
|
||||||
FileID string
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockAnimation) isRichBlock() {}
|
|
||||||
func (b BlockAnimation) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"animation", b.FileID, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockVoiceNote struct {
|
|
||||||
FileID string
|
|
||||||
Caption RichText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockVoiceNote) isRichBlock() {}
|
|
||||||
func (b BlockVoiceNote) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption RichText `json:"caption,omitempty"`
|
|
||||||
}{"voice_note", b.FileID, b.Caption})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Листья без вложенного контента
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type BlockDivider struct{}
|
|
||||||
|
|
||||||
func (BlockDivider) isRichBlock() {}
|
|
||||||
func (b BlockDivider) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
}{"divider"})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockMathematicalExpression struct {
|
|
||||||
Expression string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockMathematicalExpression) isRichBlock() {}
|
|
||||||
func (b BlockMathematicalExpression) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Expression string `json:"expression"`
|
|
||||||
}{"mathematical_expression", b.Expression})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockAnchor struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (BlockAnchor) isRichBlock() {}
|
|
||||||
func (b BlockAnchor) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}{"anchor", b.Name})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Разбор JSON -> RichBlock
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func UnmarshalBlock(data []byte) (RichBlock, error) {
|
|
||||||
var head struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text json.RawMessage `json:"text"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
Title json.RawMessage `json:"title"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &head); err != nil {
|
|
||||||
return nil, fmt.Errorf("richblock: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parseText := func(raw json.RawMessage) (RichText, error) {
|
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return Unmarshal(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
if blockWrapTags[head.Type] {
|
|
||||||
text, err := parseText(head.Text)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
|
||||||
}
|
|
||||||
return BlockWrap{Tag: head.Type, Text: text}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch head.Type {
|
|
||||||
case "preformatted":
|
|
||||||
var v struct {
|
|
||||||
Language string `json:"language"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
text, _ := parseText(head.Text)
|
|
||||||
return BlockPreformatted{text, v.Language}, nil
|
|
||||||
|
|
||||||
case "block_quotation":
|
|
||||||
text, _ := parseText(head.Text)
|
|
||||||
caption, _ := parseText(head.Caption)
|
|
||||||
return BlockBlockQuotation{text, caption}, nil
|
|
||||||
|
|
||||||
case "pull_quotation":
|
|
||||||
text, _ := parseText(head.Text)
|
|
||||||
caption, _ := parseText(head.Caption)
|
|
||||||
return BlockPullQuotation{text, caption}, nil
|
|
||||||
|
|
||||||
case "list":
|
|
||||||
var v struct {
|
|
||||||
Items []RichBlockListItem `json:"items"`
|
|
||||||
Ordered bool `json:"ordered"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &v); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return BlockList{v.Items, v.Ordered}, nil
|
|
||||||
|
|
||||||
case "collage":
|
|
||||||
var raw struct {
|
|
||||||
Items json.RawMessage `json:"items"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &raw)
|
|
||||||
items, _ := unmarshalBlocks(raw.Items)
|
|
||||||
caption, _ := parseText(head.Caption)
|
|
||||||
return BlockCollage{items, caption}, nil
|
|
||||||
|
|
||||||
case "slideshow":
|
|
||||||
var raw struct {
|
|
||||||
Items json.RawMessage `json:"items"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &raw)
|
|
||||||
items, _ := unmarshalBlocks(raw.Items)
|
|
||||||
caption, _ := parseText(head.Caption)
|
|
||||||
return BlockSlideshow{items, caption}, nil
|
|
||||||
|
|
||||||
case "details":
|
|
||||||
var raw struct {
|
|
||||||
Blocks json.RawMessage `json:"blocks"`
|
|
||||||
Open bool `json:"open"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &raw)
|
|
||||||
title, _ := parseText(head.Title)
|
|
||||||
blocks, _ := unmarshalBlocks(raw.Blocks)
|
|
||||||
return BlockDetails{title, blocks, raw.Open}, nil
|
|
||||||
|
|
||||||
case "table":
|
|
||||||
var raw struct {
|
|
||||||
Title json.RawMessage `json:"title"`
|
|
||||||
Rows [][]RichBlockTableCell `json:"rows"`
|
|
||||||
Bordered bool `json:"bordered"`
|
|
||||||
Striped bool `json:"striped"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
title, _ := parseText(raw.Title)
|
|
||||||
return BlockTable{title, raw.Rows, raw.Bordered, raw.Striped}, nil
|
|
||||||
|
|
||||||
case "map":
|
|
||||||
var v struct {
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Zoom int `json:"zoom"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockMap{v.Latitude, v.Longitude, v.Zoom, v.Width, v.Height, caption}, nil
|
|
||||||
|
|
||||||
case "photo":
|
|
||||||
var v struct {
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockPhoto{v.FileID, caption, v.URL}, nil
|
|
||||||
|
|
||||||
case "video":
|
|
||||||
var v struct {
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
Autoplay bool `json:"autoplay"`
|
|
||||||
Loop bool `json:"loop"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockVideo{v.FileID, caption, v.Autoplay, v.Loop}, nil
|
|
||||||
|
|
||||||
case "audio":
|
|
||||||
var v struct {
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockAudio{v.FileID, caption}, nil
|
|
||||||
|
|
||||||
case "animation":
|
|
||||||
var v struct {
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockAnimation{v.FileID, caption}, nil
|
|
||||||
|
|
||||||
case "voice_note":
|
|
||||||
var v struct {
|
|
||||||
FileID string `json:"file_id"`
|
|
||||||
Caption json.RawMessage `json:"caption"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
caption, _ := parseText(v.Caption)
|
|
||||||
return BlockVoiceNote{v.FileID, caption}, nil
|
|
||||||
|
|
||||||
case "divider":
|
|
||||||
return BlockDivider{}, nil
|
|
||||||
|
|
||||||
case "mathematical_expression":
|
|
||||||
var v struct {
|
|
||||||
Expression string `json:"expression"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
return BlockMathematicalExpression{v.Expression}, nil
|
|
||||||
|
|
||||||
case "anchor":
|
|
||||||
var v struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(data, &v)
|
|
||||||
return BlockAnchor{v.Name}, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
// forward-compat: неизвестный тип с text → BlockWrap, без text → ошибка.
|
|
||||||
if text, err := parseText(head.Text); err == nil && text != nil {
|
|
||||||
return BlockWrap{Tag: head.Type, Text: text}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("richblock: unknown type %q", head.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalMessage разбирает корневой RichMessage из JSON.
|
|
||||||
func UnmarshalMessage(data []byte) (RichMessage, error) {
|
|
||||||
var raw struct {
|
|
||||||
Blocks json.RawMessage `json:"blocks"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return RichMessage{}, fmt.Errorf("richmessage: %w", err)
|
|
||||||
}
|
|
||||||
blocks, err := unmarshalBlocks(raw.Blocks)
|
|
||||||
if err != nil {
|
|
||||||
return RichMessage{}, err
|
|
||||||
}
|
|
||||||
return RichMessage{blocks}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Внутренние хелперы
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func unmarshalBlocks(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 := UnmarshalBlock(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
blocks[i] = b
|
|
||||||
}
|
|
||||||
return blocks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarshalListItem(data []byte) (RichBlockListItem, error) {
|
|
||||||
var raw struct {
|
|
||||||
Blocks json.RawMessage `json:"blocks"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return RichBlockListItem{}, err
|
|
||||||
}
|
|
||||||
blocks, err := unmarshalBlocks(raw.Blocks)
|
|
||||||
if err != nil {
|
|
||||||
return RichBlockListItem{}, err
|
|
||||||
}
|
|
||||||
return RichBlockListItem{blocks}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarshalTableCell(data []byte) (RichBlockTableCell, error) {
|
|
||||||
var raw struct {
|
|
||||||
Content json.RawMessage `json:"content"`
|
|
||||||
ColumnSpan int `json:"column_span"`
|
|
||||||
RowSpan int `json:"row_span"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return RichBlockTableCell{}, err
|
|
||||||
}
|
|
||||||
content, err := unmarshalBlocks(raw.Content)
|
|
||||||
if err != nil {
|
|
||||||
return RichBlockTableCell{}, err
|
|
||||||
}
|
|
||||||
return RichBlockTableCell{content, raw.ColumnSpan, raw.RowSpan}, nil
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package richtext
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func roundtripBlock(t *testing.T, in RichBlock) {
|
|
||||||
t.Helper()
|
|
||||||
b, err := json.Marshal(in)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
out, err := UnmarshalBlock(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 TestBlockRoundtrip(t *testing.T) {
|
|
||||||
cases := []RichBlock{
|
|
||||||
// wrap-блоки
|
|
||||||
Paragraph(String("Hello, world")),
|
|
||||||
SectionHeading(Bold(String("Chapter 1"))),
|
|
||||||
Footer(String("© 2024")),
|
|
||||||
Thinking(String("Let me reason step by step.")),
|
|
||||||
|
|
||||||
// preformatted
|
|
||||||
BlockPreformatted{Text: String("fmt.Println(\"hi\")"), Language: "go"},
|
|
||||||
BlockPreformatted{Text: String("no language")},
|
|
||||||
|
|
||||||
// цитаты
|
|
||||||
BlockBlockQuotation{Text: String("To be or not to be"), Caption: String("Shakespeare")},
|
|
||||||
BlockPullQuotation{Text: String("Pull me"), Caption: nil},
|
|
||||||
|
|
||||||
// список
|
|
||||||
BlockList{
|
|
||||||
Items: []RichBlockListItem{
|
|
||||||
{Blocks: []RichBlock{Paragraph(String("item 1"))}},
|
|
||||||
{Blocks: []RichBlock{Paragraph(String("item 2"))}},
|
|
||||||
},
|
|
||||||
Ordered: true,
|
|
||||||
},
|
|
||||||
BlockList{
|
|
||||||
Items: []RichBlockListItem{
|
|
||||||
{Blocks: []RichBlock{Paragraph(String("bullet"))}},
|
|
||||||
},
|
|
||||||
Ordered: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// коллаж и слайдшоу
|
|
||||||
BlockCollage{
|
|
||||||
Items: []RichBlock{BlockPhoto{FileID: "abc123"}},
|
|
||||||
Caption: String("A photo"),
|
|
||||||
},
|
|
||||||
BlockSlideshow{
|
|
||||||
Items: []RichBlock{BlockVideo{FileID: "vid1", Autoplay: true, Loop: false}},
|
|
||||||
Caption: nil,
|
|
||||||
},
|
|
||||||
|
|
||||||
// details
|
|
||||||
BlockDetails{
|
|
||||||
Title: String("Spoiler"),
|
|
||||||
Blocks: []RichBlock{Paragraph(String("Hidden content"))},
|
|
||||||
Open: false,
|
|
||||||
},
|
|
||||||
BlockDetails{
|
|
||||||
Title: Bold(String("Open details")),
|
|
||||||
Blocks: []RichBlock{BlockDivider{}, Paragraph(String("content"))},
|
|
||||||
Open: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// таблица
|
|
||||||
BlockTable{
|
|
||||||
Title: String("Results"),
|
|
||||||
Rows: [][]RichBlockTableCell{
|
|
||||||
{
|
|
||||||
{Content: []RichBlock{Paragraph(String("Cell A1"))}},
|
|
||||||
{Content: []RichBlock{Paragraph(String("Cell A2"))}, ColumnSpan: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
{Content: []RichBlock{Paragraph(String("Cell B1"))}, RowSpan: 2},
|
|
||||||
{Content: []RichBlock{Paragraph(String("Cell B2"))}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Bordered: true,
|
|
||||||
Striped: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// карта
|
|
||||||
BlockMap{
|
|
||||||
Latitude: 55.7558, Longitude: 37.6173,
|
|
||||||
Zoom: 12, Width: 800, Height: 400,
|
|
||||||
Caption: String("Moscow"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// медиа
|
|
||||||
BlockPhoto{FileID: "photo_file_id", Caption: String("A cat"), URL: "https://example.com/cat.jpg"},
|
|
||||||
BlockPhoto{FileID: "bare_photo"},
|
|
||||||
BlockVideo{FileID: "video_file_id", Caption: String("Demo"), Autoplay: true, Loop: true},
|
|
||||||
BlockAudio{FileID: "audio_file_id", Caption: String("Podcast ep. 1")},
|
|
||||||
BlockAnimation{FileID: "anim_file_id"},
|
|
||||||
BlockVoiceNote{FileID: "voice_file_id"},
|
|
||||||
|
|
||||||
// листья
|
|
||||||
BlockDivider{},
|
|
||||||
BlockMathematicalExpression{Expression: "E = mc^2"},
|
|
||||||
BlockAnchor{Name: "section-2"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
roundtripBlock(t, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRichMessageRoundtrip(t *testing.T) {
|
|
||||||
msg := RichMessage{
|
|
||||||
Blocks: []RichBlock{
|
|
||||||
SectionHeading(String("Title")),
|
|
||||||
Paragraph(Array{String("Some "), Bold(String("bold")), String(" text")}),
|
|
||||||
BlockDivider{},
|
|
||||||
BlockList{
|
|
||||||
Items: []RichBlockListItem{
|
|
||||||
{Blocks: []RichBlock{Paragraph(String("First"))}},
|
|
||||||
{Blocks: []RichBlock{Paragraph(String("Second"))}},
|
|
||||||
},
|
|
||||||
Ordered: true,
|
|
||||||
},
|
|
||||||
BlockPhoto{FileID: "img1", Caption: String("Fig. 1")},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestBlockDividerHasNoContent(t *testing.T) {
|
|
||||||
b, _ := json.Marshal(BlockDivider{})
|
|
||||||
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 TestBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) {
|
|
||||||
raw := []byte(`{"type":"future_tag","text":"hello"}`)
|
|
||||||
b, err := UnmarshalBlock(raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("forward-compat failed: %v", err)
|
|
||||||
}
|
|
||||||
w, ok := b.(BlockWrap)
|
|
||||||
if !ok || w.Tag != "future_tag" {
|
|
||||||
t.Fatalf("expected BlockWrap{future_tag}, got %T", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBlockUnknownTypeWithoutTextIsError(t *testing.T) {
|
|
||||||
raw := []byte(`{"type":"mystery_leaf","value":42}`)
|
|
||||||
_, err := UnmarshalBlock(raw)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for unknown type without text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package richtext
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func roundtrip(t *testing.T, in RichText) {
|
|
||||||
t.Helper()
|
|
||||||
b, err := json.Marshal(in)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
out, err := Unmarshal(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 TestRoundtrip(t *testing.T) {
|
|
||||||
cases := []RichText{
|
|
||||||
String("hello"),
|
|
||||||
Array{String("a "), Bold(String("b")), String(" c")},
|
|
||||||
Bold(Italic(String("nested"))),
|
|
||||||
URL{String("Anthropic"), "https://anthropic.com"},
|
|
||||||
CustomEmoji{"5368324170671202286", "👍"},
|
|
||||||
MathematicalExpression{"x^2 + y^2"},
|
|
||||||
Anchor{"chapter-1"},
|
|
||||||
DateTime{String("22:45 tomorrow"), 1647531900, "wDT"},
|
|
||||||
TextMention{String("Bob"), tgapi.User{ID: 42, FirstName: "Bob"}},
|
|
||||||
AnchorLink{String("back to top"), ""},
|
|
||||||
Reference{String("ref"), "note-1"},
|
|
||||||
// глубокая вложенность
|
|
||||||
Bold(Array{
|
|
||||||
String("bold and "),
|
|
||||||
Italic(Underline(String("deep"))),
|
|
||||||
Spoiler(CustomEmoji{"1", "x"}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
roundtrip(t, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlainFormsAreBare(t *testing.T) {
|
|
||||||
b, _ := json.Marshal(String("hi"))
|
|
||||||
if string(b) != `"hi"` {
|
|
||||||
t.Fatalf("string should be bare: %s", b)
|
|
||||||
}
|
|
||||||
b, _ = json.Marshal(Array{String("a"), String("b")})
|
|
||||||
if string(b) != `["a","b"]` {
|
|
||||||
t.Fatalf("array should be bare: %s", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLeafHasNoText(t *testing.T) {
|
|
||||||
b, _ := json.Marshal(Anchor{"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,15 @@ package tgfmt
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func escapeRich(s string) Rich { return Rich(escapeHTML(s)) }
|
||||||
|
|
||||||
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
||||||
func EscapePunctuation(s string) string {
|
func EscapePunctuation(s string) string {
|
||||||
symbols := []string{".", "!", "-"}
|
symbols := []string{".", "!", "-"}
|
||||||
|
|||||||
+3
-3
@@ -2,13 +2,13 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// VersionString is the module version string.
|
||||||
VersionString = "1.0.2"
|
VersionString = "1.1.0"
|
||||||
// VersionMajor is the module major version.
|
// VersionMajor is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
VersionMinor = 0
|
VersionMinor = 1
|
||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 2
|
VersionPatch = 0
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 0
|
VersionBeta = 0
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user