diff --git a/go.mod b/go.mod index c7e09f6..fd03323 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/bots-go-framework/bots-api-telegram go 1.25 require ( - github.com/bots-go-framework/bots-go-core v0.2.5 + github.com/bots-go-framework/bots-go-core v0.2.6 github.com/stretchr/testify v1.11.1 github.com/strongo/logus v0.4.1 github.com/technoweenie/multipartstreamer v1.0.1 diff --git a/go.sum b/go.sum index 8876a32..8d5f14f 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,5 @@ -github.com/bots-go-framework/bots-go-core v0.1.0 h1:mZZ0QC/CfvsLufYjkJR+nYeXkbNEJ5IXwAn2noxpzTE= -github.com/bots-go-framework/bots-go-core v0.1.0/go.mod h1:XCn9z4TI8sbgwyus+VDzw7iMY2QCPWEAvl23GMDjeEU= -github.com/bots-go-framework/bots-go-core v0.2.1 h1:LN2575Fv+441LSQWae+R8nZzt1JbGRdVK1SP2Yt4Skk= -github.com/bots-go-framework/bots-go-core v0.2.1/go.mod h1:XCn9z4TI8sbgwyus+VDzw7iMY2QCPWEAvl23GMDjeEU= -github.com/bots-go-framework/bots-go-core v0.2.2 h1:9Wi8L4Y1sCpHiYAVejgXkwrezwk2r2yIF9I3IG3iJzc= -github.com/bots-go-framework/bots-go-core v0.2.2/go.mod h1:XCn9z4TI8sbgwyus+VDzw7iMY2QCPWEAvl23GMDjeEU= -github.com/bots-go-framework/bots-go-core v0.2.3 h1:wqdeyDJ+f+3W0RyFu7h46dkHoXoa74mOltmCM6W0tQc= -github.com/bots-go-framework/bots-go-core v0.2.3/go.mod h1:XCn9z4TI8sbgwyus+VDzw7iMY2QCPWEAvl23GMDjeEU= -github.com/bots-go-framework/bots-go-core v0.2.4 h1:ppsw1MohTCFIDjp5tmccAokRAeq2PcuhNQooxsN4fFc= -github.com/bots-go-framework/bots-go-core v0.2.4/go.mod h1:XCn9z4TI8sbgwyus+VDzw7iMY2QCPWEAvl23GMDjeEU= -github.com/bots-go-framework/bots-go-core v0.2.5 h1:Uv9zb23yHJY/GnfEF6ocucAmfS12pwFHiig29ZoSqME= -github.com/bots-go-framework/bots-go-core v0.2.5/go.mod h1:O/J3Q4HhTpbsbCL87Zhlf1BPtvfVp9UcfBWsc8D8ghw= +github.com/bots-go-framework/bots-go-core v0.2.6 h1:OnDcCNXWLhZAiK9kvwfHOi1wnI6P13r9Qp5q295T4So= +github.com/bots-go-framework/bots-go-core v0.2.6/go.mod h1:O/J3Q4HhTpbsbCL87Zhlf1BPtvfVp9UcfBWsc8D8ghw= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,16 +15,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/strongo/logus v0.2.1 h1:ZzAtdSg6PPF1Y43WbNWl7MH1XYl7kvKMxyxv/lpbadI= -github.com/strongo/logus v0.2.1/go.mod h1:sd8gjJklqGQAg+Q0mlP5MWgzguAuxR25YYFSkfVOPdc= -github.com/strongo/logus v0.4.0 h1:EdwNafWX1eGWvPqMvu3gHVqXtBOUvV9KWbxm6tCU+dU= -github.com/strongo/logus v0.4.0/go.mod h1:sd8gjJklqGQAg+Q0mlP5MWgzguAuxR25YYFSkfVOPdc= github.com/strongo/logus v0.4.1 h1:jEkcT5JzawDYjexRVHkCQiG4tv6Bm2a/c/ZdLk0IPqM= github.com/strongo/logus v0.4.1/go.mod h1:sd8gjJklqGQAg+Q0mlP5MWgzguAuxR25YYFSkfVOPdc= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= diff --git a/tgbotapi/api_v10_0_test.go b/tgbotapi/api_v10_0_test.go index 1d81eec..dfb57da 100644 --- a/tgbotapi/api_v10_0_test.go +++ b/tgbotapi/api_v10_0_test.go @@ -56,29 +56,31 @@ func TestUpdateGuestMessage(t *testing.T) { // TestSentGuestMessage verifies round-tripping of the SentGuestMessage class returned by // answerGuestQuery (Bot API 10.0). func TestSentGuestMessage(t *testing.T) { - data := `{"message_id": 55}` + data := `{"inline_message_id": "guest-inline-55"}` var sent SentGuestMessage require.NoError(t, json.Unmarshal([]byte(data), &sent)) - assert.Equal(t, 55, sent.MessageID) + assert.Equal(t, "guest-inline-55", sent.InlineMessageID) } // TestBotAccessSettings verifies round-tripping of BotAccessSettings, used by // getManagedBotAccessSettings/setManagedBotAccessSettings (Bot API 10.0). func TestBotAccessSettings(t *testing.T) { settings := BotAccessSettings{ - CanReadMessages: true, - CanSendMessages: true, - CanManageSettings: false, + IsAccessRestricted: true, + AddedUsers: []User{ + {ID: 42, FirstName: "Player"}, + }, } data, err := encodeToJson(settings) require.NoError(t, err) - var decoded map[string]any + var decoded BotAccessSettings require.NoError(t, json.Unmarshal(data, &decoded)) - assert.Equal(t, true, decoded["can_read_messages"]) - assert.Equal(t, true, decoded["can_send_messages"]) - assert.NotContains(t, decoded, "can_manage_settings") + assert.True(t, decoded.IsAccessRestricted) + require.Len(t, decoded.AddedUsers, 1) + assert.EqualValues(t, 42, decoded.AddedUsers[0].ID) + assert.Equal(t, "Player", decoded.AddedUsers[0].FirstName) } // TestLivePhotoUnmarshal verifies parsing of the LivePhoto class (Bot API 10.0 Live Photos). diff --git a/tgbotapi/api_v10_1_test.go b/tgbotapi/api_v10_1_test.go index 0f74066..af07a09 100644 --- a/tgbotapi/api_v10_1_test.go +++ b/tgbotapi/api_v10_1_test.go @@ -106,18 +106,100 @@ func TestRichTextCustomEmoji(t *testing.T) { // TestRichTextDateTime verifies RichTextDateTime's unix_time and date_time_format fields. func TestRichTextDateTime(t *testing.T) { - data := `{"type": "date_time", "text": "yesterday", "unix_time": 1700000000, "date_time_format": "relative"}` + data := `{"type": "date_time", "text": "yesterday", "unix_time": 1700000000, "date_time_format": "r"}` var rt RichText require.NoError(t, json.Unmarshal([]byte(data), &rt)) assert.Equal(t, RichTextTypeDateTime, rt.Type) assert.Equal(t, 1700000000, rt.UnixTime) - assert.Equal(t, "relative", rt.DateTimeFormat) + assert.Equal(t, "r", rt.DateTimeFormat) out, err := json.Marshal(rt) require.NoError(t, err) assert.JSONEq(t, data, string(out)) } +func TestRichTextDateTimeFormatValidate(t *testing.T) { + tests := []struct { + name string + format string + wantErr bool + }{ + {name: "relative", format: "r"}, + {name: "weekday", format: "w"}, + {name: "short date", format: "d"}, + {name: "long date", format: "D"}, + {name: "short time", format: "t"}, + {name: "long time", format: "T"}, + {name: "weekday short date", format: "wd"}, + {name: "weekday long date", format: "wD"}, + {name: "weekday short time", format: "wt"}, + {name: "weekday long time", format: "wT"}, + {name: "short date short time", format: "dt"}, + {name: "short date long time", format: "dT"}, + {name: "long date short time", format: "Dt"}, + {name: "long date long time", format: "DT"}, + {name: "weekday short date short time", format: "wdt"}, + {name: "weekday short date long time", format: "wdT"}, + {name: "weekday long date short time", format: "wDt"}, + {name: "weekday long date long time", format: "wDT"}, + {name: "empty", format: ""}, + {name: "word alias", format: "relative", wantErr: true}, + {name: "uppercase relative", format: "R", wantErr: true}, + {name: "relative with date", format: "rd", wantErr: true}, + {name: "date before weekday", format: "dw", wantErr: true}, + {name: "time before date", format: "td", wantErr: true}, + {name: "duplicate date", format: "dd", wantErr: true}, + {name: "duplicate time", format: "TT", wantErr: true}, + {name: "surrounding whitespace", format: " r ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := RichText{ + Type: RichTextTypeDateTime, + Text: &RichText{PlainText: "when"}, + UnixTime: 1_700_000_000, + DateTimeFormat: tt.format, + } + err := rt.Validate() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestRichTextDateTimeRequiredZeroValuesArePresentOnWire(t *testing.T) { + rt := RichText{ + Type: RichTextTypeDateTime, + Text: &RichText{PlainText: "when"}, + } + require.NoError(t, rt.Validate()) + + data, err := json.Marshal(rt) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "date_time", + "text": "when", + "unix_time": 0, + "date_time_format": "" + }`, string(data)) +} + +func TestRichTextDateTimeFieldsAreOmittedForOtherVariants(t *testing.T) { + rt := RichText{ + Type: RichTextTypeBold, + Text: &RichText{PlainText: "bold"}, + } + + data, err := json.Marshal(rt) + require.NoError(t, err) + assert.NotContains(t, string(data), "date_time_format") + assert.NotContains(t, string(data), "unix_time") +} + // TestRichBlockParagraph verifies RichBlockParagraph JSON round-tripping via the flattened RichBlock struct. func TestRichBlockParagraph(t *testing.T) { data := `{"type": "paragraph", "text": "hello"}` diff --git a/tgbotapi/api_v10_parity_test.go b/tgbotapi/api_v10_parity_test.go new file mode 100644 index 0000000..151cb9d --- /dev/null +++ b/tgbotapi/api_v10_parity_test.go @@ -0,0 +1,216 @@ +package tgbotapi + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestBaseChatCurrentFieldsValues(t *testing.T) { + values, err := (BaseChat{ + ChatID: -1001, + MessageThreadID: 2, + DirectMessagesTopicID: 3, + BusinessConnectionID: "business", + AllowPaidBroadcast: true, + SuggestedPostParameters: &SuggestedPostParameters{ + Price: &SuggestedPostPrice{Currency: "XTR", Amount: 25}, + }, + }).Values() + if err != nil { + t.Fatal(err) + } + if got := values.Get("direct_messages_topic_id"); got != "3" { + t.Errorf("direct_messages_topic_id = %q", got) + } + if got := values.Get("business_connection_id"); got != "business" { + t.Errorf("business_connection_id = %q", got) + } + if got := values["allow_paid_broadcast"]; len(got) != 1 || got[0] != "true" { + t.Errorf("allow_paid_broadcast = %#v", got) + } + var suggested SuggestedPostParameters + if err = json.Unmarshal([]byte(values.Get("suggested_post_parameters")), &suggested); err != nil { + t.Fatal(err) + } + if suggested.Price == nil || suggested.Price.Amount != 25 { + t.Errorf("suggested post = %#v", suggested) + } +} + +func TestRichMessageRecursiveValidation(t *testing.T) { + cell := RichText{PlainText: "Score"} + valid := InputRichMessage{Blocks: []InputRichBlock{{ + Type: RichBlockTypeTable, + IsBordered: true, + Cells: [][]RichBlockTableCell{{{Text: &cell, IsHeader: true}}}, + }}} + if err := valid.Validate(); err != nil { + t.Fatalf("valid table: %v", err) + } + if _, err := (RichMessageConfig{ + BaseChat: BaseChat{ChatID: 1}, + RichMessage: valid, + }).Values(); err != nil { + t.Fatalf("valid rich config: %v", err) + } + + thinking := InputRichMessage{Blocks: []InputRichBlock{{ + Type: RichBlockTypeThinking, + Text: &RichText{PlainText: "Thinking…"}, + }}} + if err := thinking.Validate(); err == nil { + t.Fatal("persistent thinking block should be rejected") + } + if err := thinking.ValidateDraft(); err != nil { + t.Fatalf("draft thinking block: %v", err) + } + + invalidMedia := InputRichMessage{ + HTML: "", + Media: []InputRichMessageMedia{{ + ID: "bad id", + Media: InputMediaPhoto{Type: "photo", Media: "file"}, + }}, + } + if err := invalidMedia.Validate(); err == nil { + t.Fatal("invalid rich media identifier should be rejected") + } +} + +func TestPollMediaTypedUnionAndLegacyLocation(t *testing.T) { + typed := NewInputPollMedia(InputMediaVideo{ + Type: "video", + Media: "video-file", + Width: 640, + Height: 360, + Duration: 5, + }) + data, err := json.Marshal(typed) + if err != nil { + t.Fatal(err) + } + var video map[string]any + if err = json.Unmarshal(data, &video); err != nil { + t.Fatal(err) + } + if video["width"] != float64(640) || video["duration"] != float64(5) { + t.Errorf("typed video lost properties: %s", data) + } + + legacy := InputPollOptionMedia{ + Type: "location", + Location: &Location{ + Latitude: 53.3, + Longitude: -6.2, + HorizontalAccuracy: 4.5, + }, + } + data, err = json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + var location map[string]any + if err = json.Unmarshal(data, &location); err != nil { + t.Fatal(err) + } + if _, nested := location["location"]; nested { + t.Fatalf("location must use flattened InputMediaLocation schema: %s", data) + } + if location["latitude"] != 53.3 || location["horizontal_accuracy"] != 4.5 { + t.Errorf("location = %s", data) + } +} + +func TestEphemeralConstructorsAndValidation(t *testing.T) { + textEdit := NewEditEphemeralMessageText(-1001, 42, 7, "Your private hand") + values, err := textEdit.Values() + if err != nil { + t.Fatal(err) + } + if values.Get("receiver_user_id") != "42" || values.Get("ephemeral_message_id") != "7" { + t.Errorf("ephemeral address = %v", values) + } + if _, err = NewDeleteEphemeralMessage(0, 42, 7).Values(); err == nil { + t.Fatal("missing chat_id should be rejected") + } + if _, err = NewEditEphemeralMessageText(-1001, 42, 7, "").Values(); err == nil { + t.Fatal("empty edit text should be rejected") + } +} + +func TestMessageDraftAllowsEmptyText(t *testing.T) { + values, err := (MessageDraftConfig{ChatID: 1, DraftID: 9}).Values() + if err != nil { + t.Fatal(err) + } + if _, exists := values["text"]; !exists { + t.Fatal("empty text must still be sent for Telegram's Thinking placeholder") + } +} + +type parityRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f parityRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func parityBot(t *testing.T, result string, inspect func(string, url.Values)) *BotAPI { + t.Helper() + envelope := `{"ok":true,"result":` + result + `}` + return NewBotAPIWithClient("1:test", &http.Client{ + Transport: parityRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + inspect(request.URL.Path, request.PostForm) + return &http.Response{ + StatusCode: http.StatusOK, + ContentLength: int64(len(envelope)), + Body: io.NopCloser(strings.NewReader(envelope)), + Header: make(http.Header), + }, nil + }), + }) +} + +func TestManagedAccessAndChatManagementWireParameters(t *testing.T) { + t.Run("managed access", func(t *testing.T) { + bot := parityBot(t, "true", func(path string, values url.Values) { + if !strings.HasSuffix(path, "/setManagedBotAccessSettings") { + t.Errorf("path = %q", path) + } + if values.Get("is_access_restricted") != "true" || strings.TrimSpace(values.Get("added_user_ids")) != "[7,8]" { + t.Errorf("values = %v", values) + } + }) + if _, err := bot.SetManagedBotAccessSettings(5, true, []int64{7, 8}); err != nil { + t.Fatal(err) + } + }) + + t.Run("return bots", func(t *testing.T) { + bot := parityBot(t, "[]", func(path string, values url.Values) { + if values.Get("return_bots") != "true" { + t.Errorf("values = %v", values) + } + }) + if _, err := bot.GetChatAdministrators("-1001", true); err != nil { + t.Fatal(err) + } + }) + + t.Run("personal messages limit", func(t *testing.T) { + bot := parityBot(t, "[]", func(path string, values url.Values) { + if values.Get("limit") != "12" { + t.Errorf("values = %v", values) + } + }) + if _, err := bot.GetUserPersonalChatMessages(5, 12); err != nil { + t.Fatal(err) + } + }) +} diff --git a/tgbotapi/bot_api.go b/tgbotapi/bot_api.go index e9bd532..7545836 100644 --- a/tgbotapi/bot_api.go +++ b/tgbotapi/bot_api.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "github.com/strongo/logus" "github.com/technoweenie/multipartstreamer" @@ -849,15 +850,23 @@ func (bot *BotAPI) GetManagedBotAccessSettings(userID int64) (settings BotAccess // SetManagedBotAccessSettings updates the access settings of a bot managed by the current bot. // // https://core.telegram.org/bots/api#setmanagedbotaccesssettings -func (bot *BotAPI) SetManagedBotAccessSettings(userID int64, settings BotAccessSettings) (APIResponse, error) { +func (bot *BotAPI) SetManagedBotAccessSettings(userID int64, isAccessRestricted bool, addedUserIDs []int64) (APIResponse, error) { + if userID == 0 { + return APIResponse{}, errors.New("user_id is required") + } + if len(addedUserIDs) > 10 { + return APIResponse{}, errors.New("added_user_ids supports at most 10 users") + } v := url.Values{} v.Add("user_id", strconv.FormatInt(userID, 10)) - - data, err := encodeToJson(settings) - if err != nil { - return APIResponse{}, err + v.Add("is_access_restricted", strconv.FormatBool(isAccessRestricted)) + if len(addedUserIDs) > 0 { + data, err := encodeToJson(addedUserIDs) + if err != nil { + return APIResponse{}, err + } + v.Add("added_user_ids", string(data)) } - v.Add("access_settings", string(data)) bot.debugLog("setManagedBotAccessSettings", v, nil) @@ -868,9 +877,23 @@ func (bot *BotAPI) SetManagedBotAccessSettings(userID int64, settings BotAccessS // their profile page. // // https://core.telegram.org/bots/api#getuserpersonalchatmessages -func (bot *BotAPI) GetUserPersonalChatMessages(userID int64) (messages []Message, err error) { +func (bot *BotAPI) GetUserPersonalChatMessages(userID int64, requestedLimit ...int) (messages []Message, err error) { + limit := 20 + if len(requestedLimit) > 1 { + return nil, errors.New("only one limit may be specified") + } + if len(requestedLimit) == 1 { + limit = requestedLimit[0] + } + if userID == 0 { + return nil, errors.New("user_id is required") + } + if limit < 1 || limit > 20 { + return nil, errors.New("limit must be between 1 and 20") + } v := url.Values{} v.Add("user_id", strconv.FormatInt(userID, 10)) + v.Add("limit", strconv.Itoa(limit)) resp, err := bot.MakeRequest("getUserPersonalChatMessages", v) if err != nil { @@ -890,10 +913,23 @@ func (bot *BotAPI) GetUserPersonalChatMessages(userID int64) (messages []Message // the bot is not a member of. // // https://core.telegram.org/bots/api#answerguestquery -func (bot *BotAPI) AnswerGuestQuery(guestQueryID, text string) (sent SentGuestMessage, err error) { +func (bot *BotAPI) AnswerGuestQuery(guestQueryID string, result InlineQueryResult) (sent SentGuestMessage, err error) { + if guestQueryID == "" { + return sent, errors.New("guest_query_id is required") + } + if result == nil { + return sent, errors.New("result is nil") + } + if err = result.Validate(); err != nil { + return sent, fmt.Errorf("invalid inline query result: %w", err) + } v := url.Values{} v.Add("guest_query_id", guestQueryID) - v.Add("text", text) + resultJSON, err := encodeToJson(result) + if err != nil { + return sent, fmt.Errorf("failed to marshal inline query result: %w", err) + } + v.Add("result", string(resultJSON)) resp, err := bot.MakeRequest("answerGuestQuery", v) if err != nil { @@ -909,6 +945,34 @@ func (bot *BotAPI) AnswerGuestQuery(guestQueryID, text string) (sent SentGuestMe return sent, nil } +// GetChatAdministrators returns chat administrators. Set returnBots to include +// administrator bots other than the current bot. +// +// https://core.telegram.org/bots/api#getchatadministrators +func (bot *BotAPI) GetChatAdministrators(chatID string, includeBots ...bool) (members []ChatMember, err error) { + if chatID == "" { + return nil, errors.New("chat_id is required") + } + if len(includeBots) > 1 { + return nil, errors.New("only one return_bots value may be specified") + } + returnBots := len(includeBots) == 1 && includeBots[0] + v := url.Values{} + v.Add("chat_id", chatID) + if returnBots { + v.Add("return_bots", "true") + } + resp, err := bot.MakeRequest("getChatAdministrators", v) + if err != nil { + return nil, err + } + if err = json.Unmarshal(resp.Result, &members); err != nil { + return nil, err + } + bot.debugLog("getChatAdministrators", v, members) + return members, nil +} + // DeleteAllMessageReactions removes all reactions from a message. Requires the can_restrict_members // administrator right. // @@ -942,6 +1006,14 @@ func (bot *BotAPI) DeleteMessageReaction(chatID int64, messageID int, userID int // // https://core.telegram.org/bots/api#answerchatjoinrequestquery func (bot *BotAPI) AnswerChatJoinRequestQuery(chatJoinRequestQueryID string, result ChatJoinRequestQueryResult) (APIResponse, error) { + if chatJoinRequestQueryID == "" { + return APIResponse{}, errors.New("chat_join_request_query_id is required") + } + switch result { + case ChatJoinRequestQueryResultApprove, ChatJoinRequestQueryResultDecline, ChatJoinRequestQueryResultQueue: + default: + return APIResponse{}, fmt.Errorf("invalid chat join request result %q", result) + } v := url.Values{} v.Add("chat_join_request_query_id", chatJoinRequestQueryID) v.Add("result", string(result)) @@ -957,6 +1029,12 @@ func (bot *BotAPI) AnswerChatJoinRequestQuery(chatJoinRequestQueryID string, res // // https://core.telegram.org/bots/api#sendchatjoinrequestwebapp func (bot *BotAPI) SendChatJoinRequestWebApp(chatJoinRequestQueryID, webAppURL string) (APIResponse, error) { + if chatJoinRequestQueryID == "" { + return APIResponse{}, errors.New("chat_join_request_query_id is required") + } + if webAppURL == "" { + return APIResponse{}, errors.New("web_app_url is required") + } v := url.Values{} v.Add("chat_join_request_query_id", chatJoinRequestQueryID) v.Add("web_app_url", webAppURL) diff --git a/tgbotapi/button_style_test.go b/tgbotapi/button_style_test.go new file mode 100644 index 0000000..d0d4eb5 --- /dev/null +++ b/tgbotapi/button_style_test.go @@ -0,0 +1,45 @@ +package tgbotapi + +import ( + "strings" + "testing" +) + +func TestCurrentButtonStyles(t *testing.T) { + for _, style := range []string{ + "", + ButtonStylePrimary, + ButtonStyleSuccess, + ButtonStyleDanger, + } { + t.Run(style, func(t *testing.T) { + inline := NewInlineKeyboardButtonData("Play", "play") + inline.Style = style + if err := inline.Validate(); err != nil { + t.Fatalf("InlineKeyboardButton.Validate() error = %v", err) + } + + keyboard := KeyboardButton{Text: "Play", Style: style} + if err := keyboard.Validate(); err != nil { + t.Fatalf("KeyboardButton.Validate() error = %v", err) + } + }) + } +} + +func TestRejectsObsoleteButtonStyles(t *testing.T) { + for _, style := range []string{"default", "positive", "destructive"} { + t.Run(style, func(t *testing.T) { + inline := NewInlineKeyboardButtonData("Play", "play") + inline.Style = style + if err := inline.Validate(); err == nil || !strings.Contains(err.Error(), "invalid button style") { + t.Fatalf("InlineKeyboardButton.Validate() error = %v, want invalid button style", err) + } + + keyboard := KeyboardButton{Text: "Play", Style: style} + if err := keyboard.Validate(); err == nil || !strings.Contains(err.Error(), "invalid button style") { + t.Fatalf("KeyboardButton.Validate() error = %v, want invalid button style", err) + } + }) + } +} diff --git a/tgbotapi/config_ephemeral_message.go b/tgbotapi/config_ephemeral_message.go index e09a510..6ca8fcf 100644 --- a/tgbotapi/config_ephemeral_message.go +++ b/tgbotapi/config_ephemeral_message.go @@ -30,6 +30,15 @@ type baseEphemeralMessageEdit struct { // //goland:noinspection GoMixedReceiverTypes func (v baseEphemeralMessageEdit) Values() (url.Values, error) { + if v.ChatID == 0 && v.ChannelUsername == "" { + return nil, fmt.Errorf("chat_id is required") + } + if v.ReceiverUserID == 0 { + return nil, fmt.Errorf("receiver_user_id is required") + } + if v.EphemeralMessageID == 0 { + return nil, fmt.Errorf("ephemeral_message_id is required") + } values := url.Values{} if v.ChannelUsername != "" { values.Add("chat_id", v.ChannelUsername) @@ -71,6 +80,15 @@ type EditEphemeralMessageTextConfig struct { LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` } +// NewEditEphemeralMessageText constructs an externally usable ephemeral text +// edit without exposing the package's shared addressing implementation. +func NewEditEphemeralMessageText(chatID, receiverUserID, ephemeralMessageID int64, text string) EditEphemeralMessageTextConfig { + return EditEphemeralMessageTextConfig{ + baseEphemeralMessageEdit: newBaseEphemeralMessageEdit(chatID, receiverUserID, ephemeralMessageID), + Text: text, + } +} + // Values returns URL values representation of EditEphemeralMessageTextConfig // //goland:noinspection GoMixedReceiverTypes @@ -79,6 +97,12 @@ func (v EditEphemeralMessageTextConfig) Values() (url.Values, error) { if err != nil { return values, err } + if len([]rune(v.Text)) == 0 || len([]rune(v.Text)) > 4096 { + return values, fmt.Errorf("text must contain 1-4096 characters") + } + if v.ParseMode != "" && len(v.Entities) > 0 { + return values, fmt.Errorf("parse_mode and entities are mutually exclusive") + } values.Add("text", v.Text) if v.ParseMode != "" { @@ -122,6 +146,14 @@ type EditEphemeralMessageMediaConfig struct { Media any `json:"media"` } +// NewEditEphemeralMessageMedia constructs an ephemeral media edit. +func NewEditEphemeralMessageMedia(chatID, receiverUserID, ephemeralMessageID int64, media any) EditEphemeralMessageMediaConfig { + return EditEphemeralMessageMediaConfig{ + baseEphemeralMessageEdit: newBaseEphemeralMessageEdit(chatID, receiverUserID, ephemeralMessageID), + Media: media, + } +} + // Values returns URL values representation of EditEphemeralMessageMediaConfig // //goland:noinspection GoMixedReceiverTypes @@ -130,6 +162,9 @@ func (v EditEphemeralMessageMediaConfig) Values() (url.Values, error) { if err != nil { return values, err } + if v.Media == nil { + return values, fmt.Errorf("media is required") + } data, err := encodeToJson(v.Media) if err != nil { @@ -165,6 +200,14 @@ type EditEphemeralMessageCaptionConfig struct { CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` } +// NewEditEphemeralMessageCaption constructs an ephemeral caption edit. +func NewEditEphemeralMessageCaption(chatID, receiverUserID, ephemeralMessageID int64, caption string) EditEphemeralMessageCaptionConfig { + return EditEphemeralMessageCaptionConfig{ + baseEphemeralMessageEdit: newBaseEphemeralMessageEdit(chatID, receiverUserID, ephemeralMessageID), + Caption: caption, + } +} + // Values returns URL values representation of EditEphemeralMessageCaptionConfig // //goland:noinspection GoMixedReceiverTypes @@ -173,6 +216,12 @@ func (v EditEphemeralMessageCaptionConfig) Values() (url.Values, error) { if err != nil { return values, err } + if len([]rune(v.Caption)) > 1024 { + return values, fmt.Errorf("caption must contain at most 1024 characters") + } + if v.ParseMode != "" && len(v.CaptionEntities) > 0 { + return values, fmt.Errorf("parse_mode and caption_entities are mutually exclusive") + } values.Add("caption", v.Caption) if v.ParseMode != "" { @@ -204,6 +253,18 @@ type EditEphemeralMessageReplyMarkupConfig struct { baseEphemeralMessageEdit } +// NewEditEphemeralMessageReplyMarkup constructs an ephemeral keyboard edit. +func NewEditEphemeralMessageReplyMarkup(chatID, receiverUserID, ephemeralMessageID int64, replyMarkup *InlineKeyboardMarkup) EditEphemeralMessageReplyMarkupConfig { + return EditEphemeralMessageReplyMarkupConfig{ + baseEphemeralMessageEdit: baseEphemeralMessageEdit{ + ChatID: chatID, + ReceiverUserID: receiverUserID, + EphemeralMessageID: ephemeralMessageID, + ReplyMarkup: replyMarkup, + }, + } +} + //goland:noinspection GoMixedReceiverTypes func (v EditEphemeralMessageReplyMarkupConfig) Values() (url.Values, error) { return v.baseEphemeralMessageEdit.Values() @@ -223,6 +284,21 @@ type DeleteEphemeralMessageConfig struct { baseEphemeralMessageEdit } +// NewDeleteEphemeralMessage constructs an ephemeral-message deletion. +func NewDeleteEphemeralMessage(chatID, receiverUserID, ephemeralMessageID int64) DeleteEphemeralMessageConfig { + return DeleteEphemeralMessageConfig{ + baseEphemeralMessageEdit: newBaseEphemeralMessageEdit(chatID, receiverUserID, ephemeralMessageID), + } +} + +func newBaseEphemeralMessageEdit(chatID, receiverUserID, ephemeralMessageID int64) baseEphemeralMessageEdit { + return baseEphemeralMessageEdit{ + ChatID: chatID, + ReceiverUserID: receiverUserID, + EphemeralMessageID: ephemeralMessageID, + } +} + //goland:noinspection GoMixedReceiverTypes func (v DeleteEphemeralMessageConfig) Values() (url.Values, error) { return v.baseEphemeralMessageEdit.Values() diff --git a/tgbotapi/config_reply_parameters.go b/tgbotapi/config_reply_parameters.go index ade4b1a..db7be81 100644 --- a/tgbotapi/config_reply_parameters.go +++ b/tgbotapi/config_reply_parameters.go @@ -1,5 +1,11 @@ package tgbotapi +import ( + "encoding/json" + "errors" + "fmt" +) + // ReplyParameters describes reply parameters for the message that is being sent. type ReplyParameters struct { // Optional. Identifier of the message that will be replied to in the current chat, or in the chat @@ -7,8 +13,8 @@ type ReplyParameters struct { MessageID int64 `json:"message_id,omitempty"` // Optional. If the message to be replied to is from a different chat, unique identifier for the chat or username of the channel (in the format @channelusername). Not supported for messages sent on behalf of a business account. - ChatIDInt int64 - ChatIDStr string + ChatIDInt int64 `json:"-"` + ChatIDStr string `json:"-"` AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` // Optional. Pass True if the message should be sent even if the specified message to be replied to is not found. Always False for replies in another chat or forum topic. Always True for messages sent on behalf of a business account. Quote string `json:"quote,omitempty"` // Optional. Quoted part of the message to be replied to; 0-1024 characters after entities parsing. The quote must be an exact substring of the message to be replied to, including bold, italic, underline, strikethrough, spoiler, and custom_emoji entities. The message will fail to send if the quote isn't found in the original message. @@ -25,3 +31,50 @@ type ReplyParameters struct { // MessageID and EphemeralMessageID are mutually exclusive - set exactly one. Bot API 10.2+ EphemeralMessageID int64 `json:"ephemeral_message_id,omitempty"` } + +// Validate checks the mutually exclusive reply target and formatting fields. +func (v ReplyParameters) Validate() error { + if v.MessageID == 0 && v.EphemeralMessageID == 0 { + return errors.New("message_id or ephemeral_message_id is required") + } + if v.MessageID != 0 && v.EphemeralMessageID != 0 { + return errors.New("message_id and ephemeral_message_id are mutually exclusive") + } + if v.ChatIDInt != 0 && v.ChatIDStr != "" { + return errors.New("ChatIDInt and ChatIDStr are mutually exclusive") + } + if v.QuoteParseMode != "" && len(v.QuoteEntities) > 0 { + return errors.New("quote_parse_mode and quote_entities are mutually exclusive") + } + return nil +} + +type replyParametersAlias ReplyParameters + +// MarshalJSON emits the polymorphic chat_id field under Telegram's exact wire +// name while retaining the historical typed ChatIDInt/ChatIDStr Go API. +func (v ReplyParameters) MarshalJSON() ([]byte, error) { + if err := v.Validate(); err != nil { + return nil, err + } + raw, err := json.Marshal(replyParametersAlias(v)) + if err != nil { + return nil, err + } + if v.ChatIDInt == 0 && v.ChatIDStr == "" { + return raw, nil + } + var object map[string]json.RawMessage + if err = json.Unmarshal(raw, &object); err != nil { + return nil, err + } + if v.ChatIDStr != "" { + object["chat_id"], err = json.Marshal(v.ChatIDStr) + } else { + object["chat_id"], err = json.Marshal(v.ChatIDInt) + } + if err != nil { + return nil, fmt.Errorf("failed to marshal reply chat_id: %w", err) + } + return json.Marshal(object) +} diff --git a/tgbotapi/config_send_message_draft.go b/tgbotapi/config_send_message_draft.go new file mode 100644 index 0000000..bde92e3 --- /dev/null +++ b/tgbotapi/config_send_message_draft.go @@ -0,0 +1,53 @@ +package tgbotapi + +import ( + "fmt" + "net/url" + "strconv" +) + +// MessageDraftConfig streams a temporary text preview while a response is generated. +// Text may be empty to display Telegram's native “Thinking…” placeholder. +type MessageDraftConfig struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int64 `json:"message_thread_id,omitempty"` + DraftID int64 `json:"draft_id"` + Text string `json:"text,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + Entities []MessageEntity `json:"entities,omitempty"` +} + +func (v MessageDraftConfig) Values() (url.Values, error) { + if v.ChatID == 0 { + return nil, fmt.Errorf("chat_id is required") + } + if v.DraftID == 0 { + return nil, fmt.Errorf("draft_id must be non-zero") + } + values := url.Values{} + values.Add("chat_id", strconv.FormatInt(v.ChatID, 10)) + if v.MessageThreadID != 0 { + values.Add("message_thread_id", strconv.FormatInt(v.MessageThreadID, 10)) + } + values.Add("draft_id", strconv.FormatInt(v.DraftID, 10)) + // text is intentionally always included: an empty value requests Telegram's + // native Thinking placeholder. + values.Add("text", v.Text) + if v.ParseMode != "" { + values.Add("parse_mode", v.ParseMode) + } + if len(v.Entities) > 0 { + data, err := encodeToJson(v.Entities) + if err != nil { + return nil, err + } + values.Add("entities", string(data)) + } + return values, nil +} + +func (MessageDraftConfig) TelegramMethod() string { + return "sendMessageDraft" +} + +var _ Sendable = MessageDraftConfig{} diff --git a/tgbotapi/config_send_poll.go b/tgbotapi/config_send_poll.go index d496598..a76906b 100644 --- a/tgbotapi/config_send_poll.go +++ b/tgbotapi/config_send_poll.go @@ -1,6 +1,7 @@ package tgbotapi import ( + "encoding/json" "fmt" "net/url" ) @@ -27,8 +28,12 @@ type InputPollOption struct { // // https://core.telegram.org/bots/api#inputpollmedia type InputPollMedia struct { + // Input is an optional fully typed InputMedia* union member. When set, it is + // serialized directly and the legacy flattened fields below are ignored. + Input any `json:"-"` + // Type of the media, one of "animation", "audio", "document", "live_photo", "location", "photo", - // "sticker", "venue", "video" + // "venue", "video" Type string `json:"type"` // File to send, required for file-based types ("animation", "audio", "document", "live_photo", @@ -48,6 +53,10 @@ type InputPollMedia struct { // // https://core.telegram.org/bots/api#inputpolloptionmedia type InputPollOptionMedia struct { + // Input is an optional fully typed InputMedia* union member. When set, it is + // serialized directly and the legacy flattened fields below are ignored. + Input any `json:"-"` + // Type of the media, one of "animation", "link", "live_photo", "location", "photo", "sticker", // "venue", "video". "link" added in Bot API 10.1 Type string `json:"type"` @@ -68,6 +77,189 @@ type InputPollOptionMedia struct { Venue *Venue `json:"venue,omitempty"` } +// NewInputPollMedia constructs a full-fidelity InputPollMedia union. +func NewInputPollMedia(input any) InputPollMedia { + return InputPollMedia{Input: input} +} + +// NewInputPollOptionMedia constructs a full-fidelity InputPollOptionMedia union. +func NewInputPollOptionMedia(input any) InputPollOptionMedia { + return InputPollOptionMedia{Input: input} +} + +func (v InputPollMedia) MarshalJSON() ([]byte, error) { + if v.Input != nil { + if err := validatePollMediaInput(v.Input, false); err != nil { + return nil, err + } + return json.Marshal(v.Input) + } + return marshalLegacyPollMedia(v.Type, v.Media, "", v.Location, v.Venue, false) +} + +func (v InputPollOptionMedia) MarshalJSON() ([]byte, error) { + if v.Input != nil { + if err := validatePollMediaInput(v.Input, true); err != nil { + return nil, err + } + return json.Marshal(v.Input) + } + return marshalLegacyPollMedia(v.Type, v.Media, v.URL, v.Location, v.Venue, true) +} + +func marshalLegacyPollMedia(mediaType, media, linkURL string, location *Location, venue *Venue, option bool) ([]byte, error) { + switch mediaType { + case "location": + if location == nil { + return nil, fmt.Errorf("location is required for poll media type %q", mediaType) + } + return json.Marshal(InputMediaLocation{ + Type: mediaType, + Latitude: location.Latitude, + Longitude: location.Longitude, + HorizontalAccuracy: location.HorizontalAccuracy, + }) + case "venue": + if venue == nil { + return nil, fmt.Errorf("venue is required for poll media type %q", mediaType) + } + return json.Marshal(InputMediaVenue{ + Type: mediaType, + Latitude: venue.Location.Latitude, + Longitude: venue.Location.Longitude, + Title: venue.Title, + Address: venue.Address, + FoursquareID: venue.FoursquareID, + FoursquareType: venue.FoursquareType, + GooglePlaceID: venue.GooglePlaceID, + GooglePlaceType: venue.GooglePlaceType, + }) + case "link": + if !option { + return nil, fmt.Errorf("link is only valid for poll-option media") + } + if linkURL == "" { + return nil, fmt.Errorf("url is required for poll media type %q", mediaType) + } + return json.Marshal(InputMediaLink{Type: mediaType, URL: linkURL}) + default: + if mediaType == "" { + return nil, fmt.Errorf("poll media type is required") + } + allowed := mediaType == "animation" || mediaType == "live_photo" || mediaType == "photo" || mediaType == "video" + if option { + allowed = allowed || mediaType == "sticker" + } else { + allowed = allowed || mediaType == "audio" || mediaType == "document" + } + if !allowed { + return nil, fmt.Errorf("media type %q is not valid for this poll media union", mediaType) + } + if media == "" { + return nil, fmt.Errorf("media is required for poll media type %q", mediaType) + } + return json.Marshal(struct { + Type string `json:"type"` + Media string `json:"media"` + }{Type: mediaType, Media: media}) + } +} + +func validatePollMediaInput(input any, option bool) error { + switch media := input.(type) { + case InputMediaAnimation: + return validateTypedMedia(media.Type, "animation", media.Media) + case *InputMediaAnimation: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validateTypedMedia(media.Type, "animation", media.Media) + case InputMediaLivePhoto: + return validateTypedMedia(media.Type, "live_photo", media.Media) + case *InputMediaLivePhoto: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validateTypedMedia(media.Type, "live_photo", media.Media) + case InputMediaLocation: + if media.Type != "location" { + return fmt.Errorf("location type must be %q", "location") + } + return nil + case *InputMediaLocation: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + case InputMediaPhoto: + return validateTypedMedia(media.Type, "photo", media.Media) + case *InputMediaPhoto: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validateTypedMedia(media.Type, "photo", media.Media) + case InputMediaVenue: + if media.Type != "venue" || media.Title == "" || media.Address == "" { + return fmt.Errorf("venue type, title, and address are required") + } + return nil + case *InputMediaVenue: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + case InputMediaVideo: + return validateTypedMedia(media.Type, "video", media.Media) + case *InputMediaVideo: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validateTypedMedia(media.Type, "video", media.Media) + case InputMediaAudio: + if option { + return fmt.Errorf("audio is not valid poll-option media") + } + return validateTypedMedia(media.Type, "audio", media.Media) + case *InputMediaAudio: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + case InputMediaDocument: + if option { + return fmt.Errorf("document is not valid poll-option media") + } + return validateTypedMedia(media.Type, "document", media.Media) + case *InputMediaDocument: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + case InputMediaLink: + if !option || media.Type != "link" || media.URL == "" { + return fmt.Errorf("link is only valid for poll options and requires type and url") + } + return nil + case *InputMediaLink: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + case InputMediaSticker: + if !option { + return fmt.Errorf("sticker is only valid for poll options") + } + return validateTypedMedia(media.Type, "sticker", media.Media) + case *InputMediaSticker: + if media == nil { + return fmt.Errorf("poll media is nil") + } + return validatePollMediaInput(*media, option) + default: + return fmt.Errorf("unsupported poll media type %T", input) + } +} + var _ Sendable = (*PollConfig)(nil) // PollConfig contains information about a sendPoll request. diff --git a/tgbotapi/config_send_rich_message.go b/tgbotapi/config_send_rich_message.go index 1db6d32..69d40d2 100644 --- a/tgbotapi/config_send_rich_message.go +++ b/tgbotapi/config_send_rich_message.go @@ -22,6 +22,9 @@ type RichMessageConfig struct { // //goland:noinspection GoMixedReceiverTypes func (v RichMessageConfig) Values() (url.Values, error) { + if err := v.RichMessage.Validate(); err != nil { + return nil, fmt.Errorf("invalid rich message: %w", err) + } values, err := v.BaseChat.Values() if err != nil { return values, err @@ -71,6 +74,15 @@ type RichMessageDraftConfig struct { // //goland:noinspection GoMixedReceiverTypes func (v RichMessageDraftConfig) Values() (url.Values, error) { + if v.ChatID == 0 { + return nil, fmt.Errorf("chat_id is required") + } + if v.DraftID == 0 { + return nil, fmt.Errorf("draft_id must be non-zero") + } + if err := v.RichMessage.ValidateDraft(); err != nil { + return nil, fmt.Errorf("invalid rich message draft: %w", err) + } values := url.Values{} values.Add("chat_id", strconv.FormatInt(v.ChatID, 10)) if v.MessageThreadID != 0 { diff --git a/tgbotapi/configs.go b/tgbotapi/configs.go index 7e56fc8..8e7a2e0 100644 --- a/tgbotapi/configs.go +++ b/tgbotapi/configs.go @@ -120,9 +120,12 @@ type BaseChat struct { ProtectContent bool `json:"protect_content,omitempty"` // Protects the contents of the sent message from forwarding and saving // Unique identifier for the target message thread (topic) of the forum; for forum supergroups only - MessageThreadID int64 `json:"message_thread_id,omitempty"` - MessageEffectID string `json:"message_effect_id,omitempty"` - AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` // Pass True to allow up to 1000 messages per second, ignoring broadcasting limits for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance + MessageThreadID int64 `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` // Pass True to allow up to 1000 messages per second, ignoring broadcasting limits for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` // Description of the message to reply to @@ -157,13 +160,12 @@ func (j BaseChat) Values() (url.Values, error) { values.Add("protect_content", "true") } - if j.AllowPaidBroadcast { - values.Add("allow_paid_broadcast", "true") - } - if j.MessageThreadID != 0 { values.Add("message_thread_id", strconv.FormatInt(j.MessageThreadID, 10)) } + if j.DirectMessagesTopicID != 0 { + values.Add("direct_messages_topic_id", strconv.FormatInt(j.DirectMessagesTopicID, 10)) + } if j.MessageEffectID != "" { values.Add("message_effect_id", j.MessageEffectID) @@ -178,8 +180,24 @@ func (j BaseChat) Values() (url.Values, error) { if j.CallbackQueryID != "" { values.Add("callback_query_id", j.CallbackQueryID) } + if j.BusinessConnectionID != "" { + values.Add("business_connection_id", j.BusinessConnectionID) + } + if j.SuggestedPostParameters != nil { + data, err := encodeToJson(j.SuggestedPostParameters) + if err != nil { + return values, fmt.Errorf("failed to marshal suggested_post_parameters: %w", err) + } + values.Add("suggested_post_parameters", string(data)) + } if j.ReplyParameters != nil { + if err := j.ReplyParameters.Validate(); err != nil { + return values, fmt.Errorf("invalid reply_parameters: %w", err) + } + if j.ReplyParameters.EphemeralMessageID != 0 && j.ReceiverUserID == 0 { + return values, errors.New("receiver_user_id is required when replying to an ephemeral message") + } data, err := encodeToJson(j.ReplyParameters) if err != nil { return values, err @@ -233,6 +251,31 @@ func (file BaseFile) params() (map[string]string, error) { if file.ReplyToMessageID != 0 { params["reply_to_message_id"] = strconv.Itoa(file.ReplyToMessageID) } + if file.MessageThreadID != 0 { + params["message_thread_id"] = strconv.FormatInt(file.MessageThreadID, 10) + } + if file.DirectMessagesTopicID != 0 { + params["direct_messages_topic_id"] = strconv.FormatInt(file.DirectMessagesTopicID, 10) + } + if file.BusinessConnectionID != "" { + params["business_connection_id"] = file.BusinessConnectionID + } + if file.MessageEffectID != "" { + params["message_effect_id"] = file.MessageEffectID + } + if file.ProtectContent { + params["protect_content"] = "true" + } + if file.AllowPaidBroadcast { + params["allow_paid_broadcast"] = "true" + } + if file.SuggestedPostParameters != nil { + data, err := encodeToJson(file.SuggestedPostParameters) + if err != nil { + return params, fmt.Errorf("failed to marshal suggested_post_parameters: %w", err) + } + params["suggested_post_parameters"] = string(data) + } if file.ReplyMarkup != nil { data, err := encodeToJson(file.ReplyMarkup) @@ -290,9 +333,10 @@ func NewChatMessageEdit(chatID int64, messageID int) BaseEdit { // BaseEdit is base type of all chat edits. type BaseEdit struct { chatEdit - ChannelUsername string `json:",omitempty"` - InlineMessageID string `json:"inline_message_id,omitempty"` - ReplyMarkup *InlineKeyboardMarkup `json:",omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChannelUsername string `json:",omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:",omitempty"` } // Values returns URL values @@ -313,6 +357,9 @@ func (v BaseEdit) Values() (url.Values, error) { if v.InlineMessageID != "" { values.Add("inline_message_id", v.InlineMessageID) } + if v.BusinessConnectionID != "" { + values.Add("business_connection_id", v.BusinessConnectionID) + } if v.ReplyMarkup != nil { data, err := encodeToJson(v.ReplyMarkup) @@ -795,14 +842,21 @@ type EditMessageTextConfig struct { func (j EditMessageTextConfig) Values() (url.Values, error) { v, _ := j.BaseEdit.Values() - v.Add("text", j.Text) - if j.ParseMode != "" { - v.Add("parse_mode", j.ParseMode) - } - if j.DisableWebPagePreview { - v.Add("disable_web_page_preview", strconv.FormatBool(j.DisableWebPagePreview)) + // rich_message replaces text. Suppress legacy text fields when callers add + // RichMessage to a config created by NewEditMessageText. + if j.RichMessage == nil { + v.Add("text", j.Text) + if j.ParseMode != "" { + v.Add("parse_mode", j.ParseMode) + } + if j.DisableWebPagePreview { + v.Add("disable_web_page_preview", strconv.FormatBool(j.DisableWebPagePreview)) + } } if j.RichMessage != nil { + if err := j.RichMessage.Validate(); err != nil { + return v, fmt.Errorf("invalid rich message: %w", err) + } if b, err := encodeToJson(j.RichMessage); err != nil { return v, fmt.Errorf("failed to marshal rich message as JSON: %w", err) } else { diff --git a/tgbotapi/guest_mode.go b/tgbotapi/guest_mode.go index bd10ab0..0c6ce3f 100644 --- a/tgbotapi/guest_mode.go +++ b/tgbotapi/guest_mode.go @@ -5,6 +5,6 @@ package tgbotapi // // https://core.telegram.org/bots/api#sentguestmessage type SentGuestMessage struct { - // Unique identifier of the sent message - MessageID int `json:"message_id"` + // Unique identifier of the inline message sent by the guest bot. + InlineMessageID string `json:"inline_message_id"` } diff --git a/tgbotapi/input_media.go b/tgbotapi/input_media.go index f20d68a..10d36b1 100644 --- a/tgbotapi/input_media.go +++ b/tgbotapi/input_media.go @@ -119,6 +119,45 @@ type InputMediaPhoto struct { HasSpoiler bool `json:"has_spoiler,omitempty"` } +// InputMediaSticker represents a sticker file to be sent. +type InputMediaSticker struct { + Type string `json:"type"` + Media string `json:"media"` + Emoji string `json:"emoji,omitempty"` +} + +// InputMediaLocation represents a location to be sent. +type InputMediaLocation struct { + Type string `json:"type"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"` +} + +// InputMediaVenue represents a venue to be sent. +type InputMediaVenue struct { + Type string `json:"type"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Title string `json:"title"` + Address string `json:"address"` + FoursquareID string `json:"foursquare_id,omitempty"` + FoursquareType string `json:"foursquare_type,omitempty"` + GooglePlaceID string `json:"google_place_id,omitempty"` + GooglePlaceType string `json:"google_place_type,omitempty"` +} + +// InputMediaDocument represents a general file to be sent. +type InputMediaDocument struct { + Type string `json:"type"` + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"` +} + // InputMediaVideo represents a video to be sent. // // https://core.telegram.org/bots/api#inputmediavideo diff --git a/tgbotapi/managed_bot.go b/tgbotapi/managed_bot.go index b0460cf..65658c0 100644 --- a/tgbotapi/managed_bot.go +++ b/tgbotapi/managed_bot.go @@ -44,20 +44,15 @@ type PreparedKeyboardButton struct { ID string `json:"id"` } -// BotAccessSettings describes the access settings granted to a bot for managing a bot it created. Used -// by getManagedBotAccessSettings and setManagedBotAccessSettings. +// BotAccessSettings describes who can access a managed bot. // // https://core.telegram.org/bots/api#botaccesssettings type BotAccessSettings struct { - // Optional. True, if the bot can read messages sent to the managed bot - CanReadMessages bool `json:"can_read_messages,omitempty"` + // True if only selected users can access the bot. The owner always has access. + IsAccessRestricted bool `json:"is_access_restricted"` - // Optional. True, if the bot can send messages on behalf of the managed bot - CanSendMessages bool `json:"can_send_messages,omitempty"` - - // Optional. True, if the bot can change the managed bot's settings, such as its name, - // description and commands - CanManageSettings bool `json:"can_manage_settings,omitempty"` + // Optional. Users who have access in addition to the owner. + AddedUsers []User `json:"added_users,omitempty"` } // PollOptionAdded describes a service message about a new option being added to a poll. diff --git a/tgbotapi/new_types.go b/tgbotapi/new_types.go index c21ba49..60239b1 100644 --- a/tgbotapi/new_types.go +++ b/tgbotapi/new_types.go @@ -3,14 +3,20 @@ package tgbotapi // SuggestedPostInfo contains information about suggested post parameters. // https://core.telegram.org/bots/api#suggestedpostinfo type SuggestedPostInfo struct { - Price *SuggestedPostPrice `json:"price,omitempty"` - SuggestedPostDate int `json:"suggested_post_date,omitempty"` + State string `json:"state"` + Price *SuggestedPostPrice `json:"price,omitempty"` + SendDate int `json:"send_date,omitempty"` + + // Deprecated: pre-release bindings used suggested_post_date. Retained for + // decoding stored updates; current Telegram updates use SendDate. + SuggestedPostDate int `json:"suggested_post_date,omitempty"` } // SuggestedPostPrice describes the price of a suggested post. // https://core.telegram.org/bots/api#suggestedpostprice type SuggestedPostPrice struct { - StarCount int `json:"star_count"` + Currency string `json:"currency"` + Amount int64 `json:"amount"` } // SuggestedPostApproved represents a service message about the approval of a suggested post. @@ -42,8 +48,8 @@ type SuggestedPostRefunded struct { // DirectMessagesTopic represents information about a direct messages chat topic. // https://core.telegram.org/bots/api#directmessagestopic type DirectMessagesTopic struct { - MessageThreadID int `json:"message_thread_id"` - Name string `json:"name,omitempty"` + TopicID int64 `json:"topic_id"` + User *User `json:"user,omitempty"` } // ChatOwnerLeft represents a service message: chat owner has left the chat. diff --git a/tgbotapi/rich_message.go b/tgbotapi/rich_message.go index 2ecd985..d211568 100644 --- a/tgbotapi/rich_message.go +++ b/tgbotapi/rich_message.go @@ -1,7 +1,5 @@ package tgbotapi -import "errors" - // RichMessage represents a rich formatted message (Bot API 10.1 Rich Messages). // // https://core.telegram.org/bots/api#richmessage @@ -41,26 +39,15 @@ type InputRichMessage struct { SkipEntityDetection bool `json:"skip_entity_detection,omitempty"` } -// Validate checks that exactly one of HTML, Markdown, or Blocks is set. +// Validate checks the persistent-message constraints recursively. func (v InputRichMessage) Validate() error { - set := 0 - if v.HTML != "" { - set++ - } - if v.Markdown != "" { - set++ - } - if len(v.Blocks) > 0 { - set++ - } - switch set { - case 0: - return errors.New("one of HTML, Markdown, or Blocks must be set") - case 1: - return nil - default: - return errors.New("only one of HTML, Markdown, or Blocks may be set") - } + return v.validate(false) +} + +// ValidateDraft checks rich-message constraints while allowing the outgoing-only +// InputRichBlockThinking block. +func (v InputRichMessage) ValidateDraft() error { + return v.validate(true) } // InputRichMessageMedia describes a media element embedded in an outgoing rich message, referenced from diff --git a/tgbotapi/rich_text.go b/tgbotapi/rich_text.go index f284476..7ffa192 100644 --- a/tgbotapi/rich_text.go +++ b/tgbotapi/rich_text.go @@ -145,7 +145,25 @@ func (r RichText) MarshalJSON() ([]byte, error) { } return json.Marshal(r.PlainText) } - return json.Marshal(richTextAlias(r)) + raw, err := json.Marshal(richTextAlias(r)) + if err != nil { + return nil, err + } + if r.Type != RichTextTypeDateTime || (r.DateTimeFormat != "" && r.UnixTime != 0) { + return raw, nil + } + + var object map[string]json.RawMessage + if err = json.Unmarshal(raw, &object); err != nil { + return nil, fmt.Errorf("failed to marshal required RichTextDateTime fields: %w", err) + } + if r.DateTimeFormat == "" { + object["date_time_format"] = json.RawMessage(`""`) + } + if r.UnixTime == 0 { + object["unix_time"] = json.RawMessage(`0`) + } + return json.Marshal(object) } // UnmarshalJSON implements the three-shape RichText wire encoding: a bare JSON string for plain text, diff --git a/tgbotapi/rich_validation.go b/tgbotapi/rich_validation.go new file mode 100644 index 0000000..11f8429 --- /dev/null +++ b/tgbotapi/rich_validation.go @@ -0,0 +1,410 @@ +package tgbotapi + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + richMediaIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + richDateTimeFormatPattern = regexp.MustCompile(`^(r|w?[dD]?[tT]?)$`) +) + +func (v InputRichMessage) validate(allowThinking bool) error { + set := 0 + if v.HTML != "" { + set++ + } + if v.Markdown != "" { + set++ + } + if len(v.Blocks) > 0 { + set++ + } + if set == 0 { + return errors.New("one of HTML, Markdown, or Blocks must be set") + } + if set > 1 { + return errors.New("only one of HTML, Markdown, or Blocks may be set") + } + if len(v.Media) > 0 && len(v.Blocks) > 0 { + return errors.New("media can only be referenced by HTML or Markdown content") + } + for i := range v.Media { + if err := v.Media[i].Validate(); err != nil { + return fmt.Errorf("media[%d]: %w", i, err) + } + } + for i := range v.Blocks { + if err := v.Blocks[i].validate(allowThinking); err != nil { + return fmt.Errorf("blocks[%d]: %w", i, err) + } + } + return nil +} + +// Validate checks the media identifier and the allowed InputMedia variant. +func (v InputRichMessageMedia) Validate() error { + if !richMediaIDPattern.MatchString(v.ID) { + return errors.New("id must contain 1-64 ASCII letters, digits, underscores, or hyphens") + } + switch media := v.Media.(type) { + case InputMediaAnimation: + return validateTypedMedia(media.Type, "animation", media.Media) + case *InputMediaAnimation: + if media == nil { + return errors.New("media is nil") + } + return validateTypedMedia(media.Type, "animation", media.Media) + case InputMediaAudio: + return validateTypedMedia(media.Type, "audio", media.Media) + case *InputMediaAudio: + if media == nil { + return errors.New("media is nil") + } + return validateTypedMedia(media.Type, "audio", media.Media) + case InputMediaPhoto: + return validateTypedMedia(media.Type, "photo", media.Media) + case *InputMediaPhoto: + if media == nil { + return errors.New("media is nil") + } + return validateTypedMedia(media.Type, "photo", media.Media) + case InputMediaVideo: + return validateTypedMedia(media.Type, "video", media.Media) + case *InputMediaVideo: + if media == nil { + return errors.New("media is nil") + } + return validateTypedMedia(media.Type, "video", media.Media) + case InputMediaVoiceNote: + return validateTypedMedia(media.Type, "voice_note", media.Media) + case *InputMediaVoiceNote: + if media == nil { + return errors.New("media is nil") + } + return validateTypedMedia(media.Type, "voice_note", media.Media) + default: + return fmt.Errorf("unsupported media type %T", v.Media) + } +} + +func validateTypedMedia(gotType, wantType, media string) error { + if gotType != wantType { + return fmt.Errorf("type must be %q, got %q", wantType, gotType) + } + if media == "" { + return errors.New("media is required") + } + return nil +} + +// Validate checks the three RichText wire shapes and recursively validates +// object/array-shaped content. +func (r RichText) Validate() error { + if r.Type == "" { + if r.Items != nil && r.PlainText != "" { + return errors.New("plain text and item array are mutually exclusive") + } + for i := range r.Items { + if err := r.Items[i].Validate(); err != nil { + return fmt.Errorf("items[%d]: %w", i, err) + } + } + return nil + } + if r.Items != nil || r.PlainText != "" { + return errors.New("object-shaped RichText can't also contain plain text or items") + } + requireText := func() error { + if r.Text == nil { + return fmt.Errorf("text is required for rich text type %q", r.Type) + } + return r.Text.Validate() + } + switch r.Type { + case RichTextTypeBold, RichTextTypeItalic, RichTextTypeUnderline, RichTextTypeStrikethrough, + RichTextTypeSpoiler, RichTextTypeSubscript, RichTextTypeSuperscript, RichTextTypeMarked, + RichTextTypeCode: + return requireText() + case RichTextTypeDateTime: + if err := requireText(); err != nil { + return err + } + if !richDateTimeFormatPattern.MatchString(r.DateTimeFormat) { + return fmt.Errorf( + "date_time_format must match r|w?[dD]?[tT]?, got %q", + r.DateTimeFormat, + ) + } + case RichTextTypeTextMention: + if err := requireText(); err != nil { + return err + } + if r.User == nil { + return errors.New("user is required") + } + case RichTextTypeCustomEmoji: + if r.CustomEmojiID == "" || r.AlternativeText == "" { + return errors.New("custom_emoji_id and alternative_text are required") + } + case RichTextTypeMathematicalExpression: + if r.Expression == "" { + return errors.New("expression is required") + } + case RichTextTypeUrl: + if err := requireText(); err != nil { + return err + } + if r.URL == "" { + return errors.New("url is required") + } + case RichTextTypeEmailAddress: + if err := requireText(); err != nil { + return err + } + if r.EmailAddress == "" { + return errors.New("email_address is required") + } + case RichTextTypePhoneNumber: + if err := requireText(); err != nil { + return err + } + if r.PhoneNumber == "" { + return errors.New("phone_number is required") + } + case RichTextTypeBankCardNumber: + if err := requireText(); err != nil { + return err + } + if r.BankCardNumber == "" { + return errors.New("bank_card_number is required") + } + case RichTextTypeMention: + if err := requireText(); err != nil { + return err + } + if r.Username == "" { + return errors.New("username is required") + } + case RichTextTypeHashtag: + if err := requireText(); err != nil { + return err + } + if r.Hashtag == "" { + return errors.New("hashtag is required") + } + case RichTextTypeCashtag: + if err := requireText(); err != nil { + return err + } + if r.Cashtag == "" { + return errors.New("cashtag is required") + } + case RichTextTypeBotCommand: + if err := requireText(); err != nil { + return err + } + if r.BotCommand == "" { + return errors.New("bot_command is required") + } + case RichTextTypeAnchor: + if r.Name == "" { + return errors.New("name is required") + } + case RichTextTypeAnchorLink: + return requireText() + case RichTextTypeReference: + if err := requireText(); err != nil { + return err + } + if r.Name == "" { + return errors.New("name is required") + } + case RichTextTypeReferenceLink: + if err := requireText(); err != nil { + return err + } + if r.ReferenceName == "" { + return errors.New("reference_name is required") + } + default: + return fmt.Errorf("unknown rich text type %q", r.Type) + } + return nil +} + +func validateRichText(field string, text *RichText) error { + if text == nil { + return fmt.Errorf("%s is required", field) + } + if err := text.Validate(); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + return nil +} + +// Validate checks a persistent InputRichBlock. Thinking blocks are only valid +// through InputRichMessage.ValidateDraft. +func (b InputRichBlock) Validate() error { + return b.validate(false) +} + +func (b InputRichBlock) validate(allowThinking bool) error { + validateBlocks := func(blocks []InputRichBlock) error { + if len(blocks) == 0 { + return errors.New("blocks are required") + } + for i := range blocks { + if err := blocks[i].validate(allowThinking); err != nil { + return fmt.Errorf("blocks[%d]: %w", i, err) + } + } + return nil + } + switch b.Type { + case RichBlockTypeParagraph, RichBlockTypePreformatted, RichBlockTypeFooter: + return validateRichText("text", b.Text) + case RichBlockTypeSectionHeading: + if err := validateRichText("text", b.Text); err != nil { + return err + } + if b.Size < 1 || b.Size > 6 { + return errors.New("heading size must be between 1 and 6") + } + case RichBlockTypeDivider: + return nil + case RichBlockTypeMathematicalExpression: + if b.Expression == "" { + return errors.New("expression is required") + } + case RichBlockTypeAnchor: + if b.Name == "" { + return errors.New("name is required") + } + case RichBlockTypeList: + if len(b.Items) == 0 { + return errors.New("items are required") + } + for i := range b.Items { + item := b.Items[i] + if item.IsChecked && !item.HasCheckbox { + return fmt.Errorf("items[%d]: is_checked requires has_checkbox", i) + } + switch item.Type { + case "", "a", "A", "i", "I", "1": + default: + return fmt.Errorf("items[%d]: invalid label type %q", i, item.Type) + } + if err := validateBlocks(item.Blocks); err != nil { + return fmt.Errorf("items[%d]: %w", i, err) + } + } + case RichBlockTypeBlockQuotation, RichBlockTypeCollage, RichBlockTypeSlideshow: + return validateBlocks(b.Blocks) + case RichBlockTypePullQuotation: + return validateRichText("text", b.Text) + case RichBlockTypeTable: + if len(b.Cells) == 0 { + return errors.New("cells are required") + } + for rowIndex := range b.Cells { + if len(b.Cells[rowIndex]) == 0 { + return fmt.Errorf("cells[%d] must not be empty", rowIndex) + } + for columnIndex := range b.Cells[rowIndex] { + cell := b.Cells[rowIndex][columnIndex] + if cell.Text != nil { + if err := cell.Text.Validate(); err != nil { + return fmt.Errorf("cells[%d][%d].text: %w", rowIndex, columnIndex, err) + } + } + if cell.Colspan < 0 || cell.Rowspan < 0 { + return fmt.Errorf("cells[%d][%d] spans can't be negative", rowIndex, columnIndex) + } + switch cell.Align { + case "", "left", "center", "right": + default: + return fmt.Errorf("cells[%d][%d] has invalid align %q", rowIndex, columnIndex, cell.Align) + } + switch cell.Valign { + case "", "top", "middle", "bottom": + default: + return fmt.Errorf("cells[%d][%d] has invalid valign %q", rowIndex, columnIndex, cell.Valign) + } + } + } + case RichBlockTypeDetails: + if err := validateRichText("summary", b.Summary); err != nil { + return err + } + return validateBlocks(b.Blocks) + case RichBlockTypeMap: + if b.Location == nil { + return errors.New("location is required") + } + if b.Zoom < 0 || b.Zoom > 24 { + return errors.New("zoom must be between 0 and 24") + } + if b.Width < 0 || b.Width > 10000 || b.Height < 0 || b.Height > 10000 || b.Width+b.Height > 10000 { + return errors.New("map width and height must each be 0-10000 and total at most 10000") + } + if b.Width > 0 && b.Height > 0 && (b.Width > b.Height*20 || b.Height > b.Width*20) { + return errors.New("map width/height ratio must be at most 20") + } + case RichBlockTypeAnimation: + if b.Animation == nil { + return errors.New("animation is required") + } + return validateTypedMedia(b.Animation.Type, "animation", b.Animation.Media) + case RichBlockTypeAudio: + if b.Audio == nil { + return errors.New("audio is required") + } + return validateTypedMedia(b.Audio.Type, "audio", b.Audio.Media) + case RichBlockTypePhoto: + if b.Photo == nil { + return errors.New("photo is required") + } + return validateTypedMedia(b.Photo.Type, "photo", b.Photo.Media) + case RichBlockTypeVideo: + if b.Video == nil { + return errors.New("video is required") + } + return validateTypedMedia(b.Video.Type, "video", b.Video.Media) + case RichBlockTypeVoiceNote: + if b.VoiceNote == nil { + return errors.New("voice_note is required") + } + return validateTypedMedia(b.VoiceNote.Type, "voice_note", b.VoiceNote.Media) + case RichBlockTypeThinking: + if !allowThinking { + return errors.New("thinking blocks are only valid in sendRichMessageDraft") + } + return validateRichText("text", b.Text) + default: + return fmt.Errorf("unknown input rich block type %q", b.Type) + } + if b.Caption != nil { + if err := b.Caption.Text.Validate(); err != nil { + return fmt.Errorf("caption.text: %w", err) + } + if b.Caption.Credit != nil { + if err := b.Caption.Credit.Validate(); err != nil { + return fmt.Errorf("caption.credit: %w", err) + } + } + } + if b.TableCaption != nil { + if err := b.TableCaption.Validate(); err != nil { + return fmt.Errorf("caption: %w", err) + } + } + if b.Credit != nil { + if err := b.Credit.Validate(); err != nil { + return fmt.Errorf("credit: %w", err) + } + } + return nil +} diff --git a/tgbotapi/suggested_post.go b/tgbotapi/suggested_post.go new file mode 100644 index 0000000..03da415 --- /dev/null +++ b/tgbotapi/suggested_post.go @@ -0,0 +1,7 @@ +package tgbotapi + +// SuggestedPostParameters contains parameters of a post suggested by the bot. +type SuggestedPostParameters struct { + Price *SuggestedPostPrice `json:"price,omitempty"` + SendDate int64 `json:"send_date,omitempty"` +} diff --git a/tgbotapi/types.go b/tgbotapi/types.go index 2631b73..46769c0 100644 --- a/tgbotapi/types.go +++ b/tgbotapi/types.go @@ -86,12 +86,38 @@ type User struct { // ChatMember holds information about chat member type ChatMember struct { + // Deprecated: use MemberUser. Kept for source compatibility with the + // framework's earlier, incomplete representation. User - IsBot bool `json:"is_bot,omitempty"` + Status string `json:"status,omitempty"` + MemberUser *User `json:"user,omitempty"` + IsBot bool `json:"is_bot,omitempty"` + + // Permission fields are populated for restricted members and administrators. + CanSendMessages bool `json:"can_send_messages,omitempty"` + CanSendAudios bool `json:"can_send_audios,omitempty"` + CanSendDocuments bool `json:"can_send_documents,omitempty"` + CanSendPhotos bool `json:"can_send_photos,omitempty"` + CanSendVideos bool `json:"can_send_videos,omitempty"` + CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"` + CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"` + CanSendPolls bool `json:"can_send_polls,omitempty"` + CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"` + CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"` + CanReactToMessages bool `json:"can_react_to_messages,omitempty"` + CanEditTag bool `json:"can_edit_tag,omitempty"` + CanChangeInfo bool `json:"can_change_info,omitempty"` + CanInviteUsers bool `json:"can_invite_users,omitempty"` + CanPinMessages bool `json:"can_pin_messages,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` + UntilDate int `json:"until_date,omitempty"` } // IsBotUser indicates if chat member is a bot func (chatMember ChatMember) IsBotUser() bool { + if chatMember.MemberUser != nil { + return chatMember.MemberUser.IsBot + } return chatMember.IsBot } @@ -198,8 +224,19 @@ type Chat struct { // Optional. True, if the chat is the direct messages chat of a channel IsDirectMessages bool `json:"is_direct_messages,omitempty"` + + // Optional. Bot assigned to process join request queries. Bot API 10.1+ + GuardBot *User `json:"guard_bot,omitempty"` + + // Optional. Community to which the chat belongs. Bot API 10.2+ + Community *Community `json:"community,omitempty"` } +// ChatFullInfo is the object returned by getChat. This package historically +// modeled the same fields directly on Chat, so the alias preserves source +// compatibility while exposing the current Bot API name. +type ChatFullInfo = Chat + // IsPrivate returns if the Chat is a private conversation. func (c *Chat) IsPrivate() bool { return c.Type == "private" @@ -353,16 +390,20 @@ type Contact struct { // Location contains information about a place. type Location struct { - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"` } // Venue contains information about a venue, including its Location. type Venue struct { - Location Location `json:"location"` - Title string `json:"title"` - Address string `json:"address"` - FoursquareID string `json:"foursquare_id,omitempty"` // optional + Location Location `json:"location"` + Title string `json:"title"` + Address string `json:"address"` + FoursquareID string `json:"foursquare_id,omitempty"` + FoursquareType string `json:"foursquare_type,omitempty"` + GooglePlaceID string `json:"google_place_id,omitempty"` + GooglePlaceType string `json:"google_place_type,omitempty"` } // UserProfilePhotos contains a set of user profile photos. @@ -588,15 +629,54 @@ type KeyboardButton struct { // Available if the bot is allowed to use custom emoji in messages. Bot API 9.4+ IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` - // Optional. The color of the button. One of "default", "positive", "destructive". Bot API 9.4+ + // Optional. The color of the button. One of "primary", "success", "danger". Bot API 9.4+ Style string `json:"style,omitempty"` } +const ( + ButtonStylePrimary = "primary" + ButtonStyleSuccess = "success" + ButtonStyleDanger = "danger" +) + +func validateButtonStyle(style string) error { + switch style { + case "", ButtonStylePrimary, ButtonStyleSuccess, ButtonStyleDanger: + return nil + default: + return fmt.Errorf("invalid button style %q: expected primary, success, or danger", style) + } +} + +// ChatPermissions describes actions that a non-administrator user may perform +// in a chat. +type ChatPermissions struct { + CanSendMessages bool `json:"can_send_messages,omitempty"` + CanSendAudios bool `json:"can_send_audios,omitempty"` + CanSendDocuments bool `json:"can_send_documents,omitempty"` + CanSendPhotos bool `json:"can_send_photos,omitempty"` + CanSendVideos bool `json:"can_send_videos,omitempty"` + CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"` + CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"` + CanSendPolls bool `json:"can_send_polls,omitempty"` + CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"` + CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"` + CanReactToMessages bool `json:"can_react_to_messages,omitempty"` + CanEditTag bool `json:"can_edit_tag,omitempty"` + CanChangeInfo bool `json:"can_change_info,omitempty"` + CanInviteUsers bool `json:"can_invite_users,omitempty"` + CanPinMessages bool `json:"can_pin_messages,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` +} + // Validate checks if the keyboard button is valid func (j *KeyboardButton) Validate() error { if j.Text == "" { return errors.New("keyboard button requires 'text' field") } + if err := validateButtonStyle(j.Style); err != nil { + return err + } return nil } @@ -760,11 +840,14 @@ type InlineKeyboardButton struct { // Available if the bot is allowed to use custom emoji in messages. Bot API 9.4+ IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` - // Optional. The color of the button. One of "default", "positive", "destructive". Bot API 9.4+ + // Optional. The color of the button. One of "primary", "success", "danger". Bot API 9.4+ Style string `json:"style,omitempty"` } func (v InlineKeyboardButton) Validate() error { + if err := validateButtonStyle(v.Style); err != nil { + return err + } var populatedFields []string if v.URL != "" { populatedFields = append(populatedFields, "url")