fix: correct Telegram update/keyboard models and harden env parsing
This commit is contained in:
@@ -202,7 +202,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
for {
|
||||
// Apply rate limiting before making the request
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotAcceptEncoding string
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
gotAcceptEncoding = req.Header.Get("Accept-Encoding")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.CloseApi(); err != nil {
|
||||
t.Fatalf("CloseApi returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
user, err := api.GetMe()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMe returned error: %v", err)
|
||||
}
|
||||
if user.FirstName != "Test" {
|
||||
t.Fatalf("unexpected first name: %q", user.FirstName)
|
||||
}
|
||||
if gotPath != "/bottoken/getMe" {
|
||||
t.Fatalf("unexpected request path: %s", gotPath)
|
||||
}
|
||||
if gotAcceptEncoding != "" {
|
||||
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ type BusinessConnection struct {
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
|
||||
// BusinessMessagesDeleted is received when messages are deleted from a connected business account.
|
||||
// See https://core.telegram.org/bots/api#businessmessagesdeleted
|
||||
type BusinessMessagesDeleted struct {
|
||||
BusinessConnectionID string `json:"business_connection_id"`
|
||||
Chat Chat `json:"chat"`
|
||||
MessageIDs []int `json:"message_ids"`
|
||||
}
|
||||
|
||||
// InputStoryContentType indicates the type of input story content.
|
||||
type InputStoryContentType string
|
||||
|
||||
|
||||
+68
-8
@@ -144,12 +144,12 @@ type LinkPreviewOptions struct {
|
||||
type ReplyMarkup struct {
|
||||
InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"`
|
||||
|
||||
Keyboard [][]int `json:"keyboard,omitempty"`
|
||||
IsPersistent bool `json:"is_persistent,omitempty"`
|
||||
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
|
||||
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
|
||||
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
|
||||
Selective bool `json:"selective,omitempty"`
|
||||
Keyboard [][]KeyboardButton `json:"keyboard,omitempty"`
|
||||
IsPersistent bool `json:"is_persistent,omitempty"`
|
||||
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
|
||||
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
|
||||
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
|
||||
Selective bool `json:"selective,omitempty"`
|
||||
|
||||
RemoveKeyboard bool `json:"remove_keyboard,omitempty"`
|
||||
|
||||
@@ -165,6 +165,60 @@ type InlineKeyboardMarkup struct {
|
||||
// KeyboardButtonStyle represents the style of a keyboard button.
|
||||
type KeyboardButtonStyle string
|
||||
|
||||
const (
|
||||
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
|
||||
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
|
||||
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
|
||||
)
|
||||
|
||||
// KeyboardButton represents one button of the reply keyboard.
|
||||
// See https://core.telegram.org/bots/api#keyboardbutton
|
||||
type KeyboardButton struct {
|
||||
Text string `json:"text"`
|
||||
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
|
||||
Style KeyboardButtonStyle `json:"style,omitempty"`
|
||||
RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"`
|
||||
RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"`
|
||||
RequestContact bool `json:"request_contact,omitempty"`
|
||||
RequestLocation bool `json:"request_location,omitempty"`
|
||||
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
|
||||
WebApp *WebAppInfo `json:"web_app,omitempty"`
|
||||
}
|
||||
|
||||
// KeyboardButtonRequestUsers defines criteria used to request suitable users.
|
||||
// See https://core.telegram.org/bots/api#keyboardbuttonrequestusers
|
||||
type KeyboardButtonRequestUsers struct {
|
||||
RequestID int `json:"request_id"`
|
||||
UserIsBot *bool `json:"user_is_bot,omitempty"`
|
||||
UserIsPremium *bool `json:"user_is_premium,omitempty"`
|
||||
MaxQuantity int `json:"max_quantity,omitempty"`
|
||||
RequestName bool `json:"request_name,omitempty"`
|
||||
RequestUsername bool `json:"request_username,omitempty"`
|
||||
RequestPhoto bool `json:"request_photo,omitempty"`
|
||||
}
|
||||
|
||||
// KeyboardButtonRequestChat defines criteria used to request a suitable chat.
|
||||
// See https://core.telegram.org/bots/api#keyboardbuttonrequestchat
|
||||
type KeyboardButtonRequestChat struct {
|
||||
RequestID int `json:"request_id"`
|
||||
ChatIsChannel bool `json:"chat_is_channel"`
|
||||
ChatIsForum *bool `json:"chat_is_forum,omitempty"`
|
||||
ChatHasUsername *bool `json:"chat_has_username,omitempty"`
|
||||
ChatIsCreated *bool `json:"chat_is_created,omitempty"`
|
||||
UserAdministratorRights *ChatAdministratorRights `json:"user_administrator_rights,omitempty"`
|
||||
BotAdministratorRights *ChatAdministratorRights `json:"bot_administrator_rights,omitempty"`
|
||||
BotIsMember bool `json:"bot_is_member,omitempty"`
|
||||
RequestTitle bool `json:"request_title,omitempty"`
|
||||
RequestUsername bool `json:"request_username,omitempty"`
|
||||
RequestPhoto bool `json:"request_photo,omitempty"`
|
||||
}
|
||||
|
||||
// KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button.
|
||||
// See https://core.telegram.org/bots/api#keyboardbuttonpolltype
|
||||
type KeyboardButtonPollType struct {
|
||||
Type PollType `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// InlineKeyboardButton represents one button of an inline keyboard.
|
||||
// See https://core.telegram.org/bots/api#inlinekeyboardbutton
|
||||
type InlineKeyboardButton struct {
|
||||
@@ -178,7 +232,12 @@ type InlineKeyboardButton struct {
|
||||
// ReplyKeyboardMarkup represents a custom keyboard with reply options.
|
||||
// See https://core.telegram.org/bots/api#replykeyboardmarkup
|
||||
type ReplyKeyboardMarkup struct {
|
||||
Keyboard [][]int `json:"keyboard"`
|
||||
Keyboard [][]KeyboardButton `json:"keyboard"`
|
||||
IsPersistent bool `json:"is_persistent,omitempty"`
|
||||
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
|
||||
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
|
||||
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
|
||||
Selective bool `json:"selective,omitempty"`
|
||||
}
|
||||
|
||||
// CallbackQuery represents an incoming callback query from a callback button in an inline keyboard.
|
||||
@@ -238,7 +297,8 @@ const (
|
||||
ChatActionUploadDocument ChatActionType = "upload_document"
|
||||
ChatActionChooseSticker ChatActionType = "choose_sticker"
|
||||
ChatActionFindLocation ChatActionType = "find_location"
|
||||
ChatActionUploadVideoNone ChatActionType = "upload_video_none"
|
||||
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
|
||||
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
|
||||
)
|
||||
|
||||
// MessageReactionUpdated represents a change of a reaction on a message.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplyKeyboardMarkupMarshalsKeyboardButtons(t *testing.T) {
|
||||
markup := ReplyKeyboardMarkup{
|
||||
Keyboard: [][]KeyboardButton{{
|
||||
{
|
||||
Text: "Create poll",
|
||||
RequestPoll: &KeyboardButtonPollType{Type: PollTypeQuiz},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(markup)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
got := string(data)
|
||||
if !strings.Contains(got, `"keyboard":[[{"text":"Create poll","request_poll":{"type":"quiz"}}]]`) {
|
||||
t.Fatalf("unexpected reply keyboard JSON: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatActionUploadVideoNoteValue(t *testing.T) {
|
||||
if ChatActionUploadVideoNote != "upload_video_note" {
|
||||
t.Fatalf("unexpected chat action value: %q", ChatActionUploadVideoNote)
|
||||
}
|
||||
if ChatActionUploadVideoNone != ChatActionUploadVideoNote {
|
||||
t.Fatalf("expected deprecated alias to match upload_video_note, got %q", ChatActionUploadVideoNone)
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -1,9 +1,12 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
)
|
||||
|
||||
// UpdateParams holds parameters for the getUpdates method.
|
||||
@@ -12,7 +15,7 @@ type UpdateParams struct {
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
AllowedUpdates []UpdateType `json:"allowed_updates"`
|
||||
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
|
||||
}
|
||||
|
||||
// GetMe returns basic information about the bot.
|
||||
@@ -103,13 +106,31 @@ func (api *API) GetFile(params GetFileP) (File, error) {
|
||||
// The link is usually obtained from File.FilePath.
|
||||
// See https://core.telegram.org/bots/api#file
|
||||
func (api *API) GetFileByLink(link string) ([]byte, error) {
|
||||
u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", api.token, link)
|
||||
res, err := http.Get(u)
|
||||
methodPrefix := ""
|
||||
if api.useTestServer {
|
||||
methodPrefix = "/test"
|
||||
}
|
||||
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||
|
||||
res, err := api.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
|
||||
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body))
|
||||
}
|
||||
return io.ReadAll(res.Body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
|
||||
var gotPath string
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("payload")),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.CloseApi(); err != nil {
|
||||
t.Fatalf("CloseApi returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := api.GetFileByLink("files/report.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileByLink returned error: %v", err)
|
||||
}
|
||||
if string(data) != "payload" {
|
||||
t.Fatalf("unexpected payload: %q", string(data))
|
||||
}
|
||||
if gotPath != "/file/bottoken/files/report.txt" {
|
||||
t.Fatalf("unexpected request path: %s", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("missing\n")),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.CloseApi(); err != nil {
|
||||
t.Fatalf("CloseApi returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := api.GetFileByLink("files/report.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-2xx response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotBody); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":[]}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.CloseApi(); err != nil {
|
||||
t.Fatalf("CloseApi returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
updates, err := api.GetUpdates(UpdateParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdates returned error: %v", err)
|
||||
}
|
||||
if len(updates) != 0 {
|
||||
t.Fatalf("expected no updates, got %d", len(updates))
|
||||
}
|
||||
if _, exists := gotBody["allowed_updates"]; exists {
|
||||
t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"])
|
||||
}
|
||||
}
|
||||
+43
-9
@@ -1,5 +1,7 @@
|
||||
package tgapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// UpdateType represents the type of incoming update.
|
||||
type UpdateType string
|
||||
|
||||
@@ -23,8 +25,10 @@ const (
|
||||
UpdateTypeBusinessMessage UpdateType = "business_message"
|
||||
// UpdateTypeEditedBusinessMessage is an edited business message update.
|
||||
UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message"
|
||||
// UpdateTypeDeletedBusinessMessage is a deleted business message update.
|
||||
UpdateTypeDeletedBusinessMessage UpdateType = "deleted_business_message"
|
||||
// UpdateTypeDeletedBusinessMessages is a deleted business messages update.
|
||||
UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages"
|
||||
// UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias.
|
||||
UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages
|
||||
|
||||
// UpdateTypeInlineQuery is an inline query update.
|
||||
UpdateTypeInlineQuery UpdateType = "inline_query"
|
||||
@@ -63,17 +67,18 @@ type Update struct {
|
||||
ChannelPost *Message `json:"channel_post,omitempty"`
|
||||
EditedChannelPost *Message `json:"edited_channel_post,omitempty"`
|
||||
|
||||
BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
|
||||
BusinessMessage *Message `json:"business_message,omitempty"`
|
||||
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
|
||||
DeletedBusinessMessage *Message `json:"deleted_business_messages,omitempty"`
|
||||
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
|
||||
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
|
||||
BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
|
||||
BusinessMessage *Message `json:"business_message,omitempty"`
|
||||
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
|
||||
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
|
||||
DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"`
|
||||
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
|
||||
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
|
||||
|
||||
InlineQuery *InlineQuery `json:"inline_query,omitempty"`
|
||||
ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"`
|
||||
CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
|
||||
ShippingQuery ShippingQuery `json:"shipping_query,omitempty"`
|
||||
ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"`
|
||||
PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"`
|
||||
PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"`
|
||||
|
||||
@@ -86,6 +91,35 @@ type Update struct {
|
||||
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
|
||||
}
|
||||
|
||||
func (u *Update) syncDeletedBusinessMessages() {
|
||||
if u.DeletedBusinessMessages != nil {
|
||||
u.DeletedBusinessMessage = u.DeletedBusinessMessages
|
||||
return
|
||||
}
|
||||
if u.DeletedBusinessMessage != nil {
|
||||
u.DeletedBusinessMessages = u.DeletedBusinessMessage
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync.
|
||||
func (u *Update) UnmarshalJSON(data []byte) error {
|
||||
type alias Update
|
||||
var aux alias
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
*u = Update(aux)
|
||||
u.syncDeletedBusinessMessages()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON emits the canonical deleted_business_messages field.
|
||||
func (u Update) MarshalJSON() ([]byte, error) {
|
||||
u.syncDeletedBusinessMessages()
|
||||
type alias Update
|
||||
return json.Marshal(alias(u))
|
||||
}
|
||||
|
||||
// InlineQuery represents an incoming inline query.
|
||||
// See https://core.telegram.org/bots/api#inlinequery
|
||||
type InlineQuery struct {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) {
|
||||
var update Update
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"update_id": 1,
|
||||
"deleted_business_messages": {
|
||||
"business_connection_id": "conn",
|
||||
"chat": {"id": 42, "type": "private"},
|
||||
"message_ids": [3, 5]
|
||||
}
|
||||
}`), &update)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
|
||||
if update.DeletedBusinessMessages == nil {
|
||||
t.Fatal("expected DeletedBusinessMessages to be populated")
|
||||
}
|
||||
if update.DeletedBusinessMessage == nil {
|
||||
t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated")
|
||||
}
|
||||
if update.DeletedBusinessMessages != update.DeletedBusinessMessage {
|
||||
t.Fatal("expected deleted business message fields to share the same payload")
|
||||
}
|
||||
if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 {
|
||||
t.Fatalf("unexpected message ids: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
|
||||
update := Update{
|
||||
UpdateID: 1,
|
||||
DeletedBusinessMessage: &BusinessMessagesDeleted{
|
||||
BusinessConnectionID: "conn",
|
||||
Chat: Chat{ID: 42, Type: string(ChatTypePrivate)},
|
||||
MessageIDs: []int{7},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
got := string(data)
|
||||
if !strings.Contains(got, `"deleted_business_messages"`) {
|
||||
t.Fatalf("expected canonical deleted_business_messages field, got %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"deleted_business_message"`) {
|
||||
t.Fatalf("unexpected singular deleted_business_message field, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
|
||||
var update Update
|
||||
if err := json.Unmarshal([]byte(`{"update_id":1}`), &update); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if update.ShippingQuery != nil {
|
||||
t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,6 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.ContentLength = int64(buf.Len())
|
||||
|
||||
up.logger.Debugln("UPLOADER REQ", r.method)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *testing.T) {
|
||||
var (
|
||||
gotPath string
|
||||
gotAcceptEncoding string
|
||||
gotFields map[string]string
|
||||
gotFileName string
|
||||
gotFileData []byte
|
||||
roundTripErr error
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
gotAcceptEncoding = req.Header.Get("Accept-Encoding")
|
||||
|
||||
gotFields, gotFileName, gotFileData, roundTripErr = readMultipartRequest(req)
|
||||
if roundTripErr != nil {
|
||||
roundTripErr = fmt.Errorf("readMultipartRequest: %w", roundTripErr)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.CloseApi(); err != nil {
|
||||
t.Fatalf("CloseApi returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
uploader := NewUploader(api)
|
||||
defer func() {
|
||||
if err := uploader.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
msg, err := uploader.SendPhoto(
|
||||
UploadPhotoP{
|
||||
ChatID: 42,
|
||||
CaptionEntities: []MessageEntity{{
|
||||
Type: MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 4,
|
||||
}},
|
||||
ReplyMarkup: &ReplyMarkup{
|
||||
InlineKeyboard: [][]InlineKeyboardButton{{
|
||||
{Text: "A", CallbackData: "b"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
NewUploaderFile("photo.jpg", []byte("img")),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendPhoto returned error: %v", err)
|
||||
}
|
||||
if msg.MessageID != 5 {
|
||||
t.Fatalf("unexpected message id: %d", msg.MessageID)
|
||||
}
|
||||
if roundTripErr != nil {
|
||||
t.Fatalf("multipart parse failed: %v", roundTripErr)
|
||||
}
|
||||
if gotPath != "/bottoken/sendPhoto" {
|
||||
t.Fatalf("unexpected request path: %s", gotPath)
|
||||
}
|
||||
if gotAcceptEncoding != "" {
|
||||
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
|
||||
}
|
||||
if got := gotFields["chat_id"]; got != "42" {
|
||||
t.Fatalf("chat_id mismatch: %q", got)
|
||||
}
|
||||
if got := gotFields["caption_entities"]; got != `[{"type":"bold","offset":0,"length":4}]` {
|
||||
t.Fatalf("caption_entities mismatch: %q", got)
|
||||
}
|
||||
if got := gotFields["reply_markup"]; got != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` {
|
||||
t.Fatalf("reply_markup mismatch: %q", got)
|
||||
}
|
||||
if gotFileName != "photo.jpg" {
|
||||
t.Fatalf("unexpected file name: %q", gotFileName)
|
||||
}
|
||||
if string(gotFileData) != "img" {
|
||||
t.Fatalf("unexpected file content: %q", string(gotFileData))
|
||||
}
|
||||
}
|
||||
|
||||
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
|
||||
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
reader := multipart.NewReader(req.Body, params["boundary"])
|
||||
|
||||
fields := make(map[string]string)
|
||||
var fileName string
|
||||
var fileData []byte
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return fields, fileName, fileData, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
if part.FileName() != "" {
|
||||
fileName = part.FileName()
|
||||
fileData = data
|
||||
continue
|
||||
}
|
||||
fields[part.FormName()] = string(data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user