fix bot lifecycle and docs
This commit is contained in:
+12
-19
@@ -124,6 +124,9 @@ func NewAPI(opts *APIOpts) *API {
|
||||
// See https://core.telegram.org/bots/api
|
||||
func (api *API) Close() error {
|
||||
api.pool.stop()
|
||||
if api.client != nil {
|
||||
api.client.CloseIdleConnections()
|
||||
}
|
||||
return api.logger.Close()
|
||||
}
|
||||
|
||||
@@ -149,37 +152,29 @@ type ApiResponse[R any] struct {
|
||||
Parameters *ResponseParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// TelegramRequest is an internal helper struct.
|
||||
// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged.
|
||||
// Instead, use explicit methods like SendMessage, GetUpdates, etc.
|
||||
// TelegramRequest is a low-level Telegram API request wrapper.
|
||||
//
|
||||
// Why? Because using generics with arbitrary types P and R leads to:
|
||||
// - No compile-time validation of parameters
|
||||
// - No IDE autocompletion
|
||||
// - Runtime panics on malformed JSON
|
||||
// - Hard-to-debug errors
|
||||
//
|
||||
// Recommended: Define specific methods for each Telegram method (see below).
|
||||
// Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest
|
||||
// bypasses method-specific parameter types and convenience helpers, so callers are
|
||||
// responsible for using the correct method name and compatible request and response types.
|
||||
// In that sense it is an unsafe escape hatch compared with the typed API surface.
|
||||
type TelegramRequest[R, P any] struct {
|
||||
method string
|
||||
params P
|
||||
chatId int64
|
||||
}
|
||||
|
||||
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID.
|
||||
// NewRequest creates a low-level TelegramRequest with no associated chat ID.
|
||||
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
||||
return TelegramRequest[R, P]{method, params, 0}
|
||||
}
|
||||
|
||||
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID.
|
||||
// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
|
||||
// The chat ID is used for per-chat rate limiting.
|
||||
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
|
||||
return TelegramRequest[R, P]{method, params, chatId}
|
||||
}
|
||||
|
||||
// doRequest performs a single HTTP request to Telegram API.
|
||||
// Handles rate limiting, retries on 429, and parses responses.
|
||||
// Must be called within a worker pool context if using DoWithContext.
|
||||
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
||||
var zero R
|
||||
reqData, err := json.Marshal(r.params)
|
||||
@@ -296,15 +291,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
|
||||
return r.DoWithContext(context.Background(), api)
|
||||
}
|
||||
|
||||
// readBody reads and limits response body to prevent memory exhaustion.
|
||||
// Telegram responses are typically small (<1MB), but we cap at 10MB.
|
||||
// Internal helper that reads and caps a Telegram response body.
|
||||
func readBody(body io.ReadCloser) ([]byte, error) {
|
||||
reader := io.LimitReader(body, 10<<20) // 10 MB
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// parseBody unmarshals a Telegram API response into a typed ApiResponse.
|
||||
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
|
||||
// Internal helper that parses a typed Telegram API response body.
|
||||
func parseBody[R any](data []byte) (ApiResponse[R], error) {
|
||||
var resp ApiResponse[R]
|
||||
err := json.Unmarshal(data, &resp)
|
||||
|
||||
@@ -13,6 +13,15 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type closingTransport struct {
|
||||
roundTripFunc
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (t *closingTransport) CloseIdleConnections() {
|
||||
t.closed = true
|
||||
}
|
||||
|
||||
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotAcceptEncoding string
|
||||
@@ -54,3 +63,28 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
|
||||
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICloseClosesIdleConnections(t *testing.T) {
|
||||
transport := &closingTransport{
|
||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
||||
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(&http.Client{Transport: transport}),
|
||||
)
|
||||
|
||||
if err := api.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
if !transport.closed {
|
||||
t.Fatal("expected Close to close idle HTTP connections")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ type SendVoiceP struct {
|
||||
|
||||
// SendVoice sends a voice note.
|
||||
// See https://core.telegram.org/bots/api#sendvoice
|
||||
func (api *API) SendVoice(params *SendVoiceP) (Message, error) {
|
||||
func (api *API) SendVoice(params SendVoiceP) (Message, error) {
|
||||
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func (api *API) SendVoice(params *SendVoiceP) (Message, error) {
|
||||
// SendVoiceWithContext is the context-aware variant of SendVoice.
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendvoice
|
||||
func (api *API) SendVoiceWithContext(ctx context.Context, params *SendVoiceP) (Message, error) {
|
||||
func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) {
|
||||
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ type InputPaidMedia struct {
|
||||
Type InputPaidMediaType `json:"type"`
|
||||
Media string `json:"media"`
|
||||
|
||||
Cover string `json:"cover"`
|
||||
StartTimestamp int64 `json:"start_timestamp"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration int `json:"duration"`
|
||||
SupportsStreaming bool `json:"supports_streaming"`
|
||||
Cover *string `json:"cover,omitempty"`
|
||||
StartTimestamp *int64 `json:"start_timestamp,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
SupportsStreaming *bool `json:"supports_streaming,omitempty"`
|
||||
}
|
||||
|
||||
// PhotoSize represents one size of a photo or a file/sticker thumbnail.
|
||||
|
||||
@@ -267,21 +267,21 @@ func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChat
|
||||
// GetChatMenuButtonP holds parameters for the getChatMenuButton method.
|
||||
// See https://core.telegram.org/bots/api#getchatmenubutton
|
||||
type GetChatMenuButtonP struct {
|
||||
ChatID int64 `json:"chat_id"`
|
||||
ChatID int64 `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
// GetChatMenuButton returns the current menu button for the given chat.
|
||||
// See https://core.telegram.org/bots/api#getchatmenubutton
|
||||
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) {
|
||||
req := NewRequest[BaseMenuButton]("getChatMenuButton", params)
|
||||
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) {
|
||||
req := NewRequest[MenuButton]("getChatMenuButton", params)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton.
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#getchatmenubutton
|
||||
func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (BaseMenuButton, error) {
|
||||
req := NewRequest[BaseMenuButton]("getChatMenuButton", params)
|
||||
func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) {
|
||||
req := NewRequest[MenuButton]("getChatMenuButton", params)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -54,7 +54,9 @@ type BotShortDescription struct {
|
||||
type InputProfilePhotoType string
|
||||
|
||||
const (
|
||||
InputProfilePhotoStaticType InputProfilePhotoType = "static"
|
||||
// InputProfilePhotoStaticType identifies a static profile photo input.
|
||||
InputProfilePhotoStaticType InputProfilePhotoType = "static"
|
||||
// InputProfilePhotoAnimatedType identifies an animated profile photo input.
|
||||
InputProfilePhotoAnimatedType InputProfilePhotoType = "animated"
|
||||
)
|
||||
|
||||
@@ -75,17 +77,20 @@ type InputProfilePhoto struct {
|
||||
type MenuButtonType string
|
||||
|
||||
const (
|
||||
// MenuButtonCommandsType identifies a commands menu button.
|
||||
MenuButtonCommandsType MenuButtonType = "commands"
|
||||
MenuButtonWebAppType MenuButtonType = "web_app"
|
||||
MenuButtonDefaultType MenuButtonType = "default"
|
||||
// MenuButtonWebAppType identifies a web app menu button.
|
||||
MenuButtonWebAppType MenuButtonType = "web_app"
|
||||
// MenuButtonDefaultType identifies Telegram's default menu button.
|
||||
MenuButtonDefaultType MenuButtonType = "default"
|
||||
)
|
||||
|
||||
// BaseMenuButton represents a menu button.
|
||||
// MenuButton represents a menu button.
|
||||
// See https://core.telegram.org/bots/api#menubutton
|
||||
type BaseMenuButton struct {
|
||||
type MenuButton struct {
|
||||
Type MenuButtonType `json:"type"`
|
||||
|
||||
// WebApp fields (for web_app button)
|
||||
Text string `json:"text"`
|
||||
WebApp WebAppInfo `json:"web_app"`
|
||||
Text *string `json:"text"`
|
||||
WebApp *WebAppInfo `json:"web_app"`
|
||||
}
|
||||
|
||||
+11
-4
@@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct {
|
||||
type InputStoryContentType string
|
||||
|
||||
const (
|
||||
// InputStoryContentPhotoType identifies photo story content.
|
||||
InputStoryContentPhotoType InputStoryContentType = "photo"
|
||||
// InputStoryContentVideoType identifies video story content.
|
||||
InputStoryContentVideoType InputStoryContentType = "video"
|
||||
)
|
||||
|
||||
@@ -106,10 +108,15 @@ type StoryAreaPosition struct {
|
||||
type StoryAreaTypeType string
|
||||
|
||||
const (
|
||||
StoryAreaTypeLocationType StoryAreaTypeType = "location"
|
||||
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
|
||||
StoryAreaTypeLinkType StoryAreaTypeType = "link"
|
||||
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
|
||||
// StoryAreaTypeLocationType identifies a location story area.
|
||||
StoryAreaTypeLocationType StoryAreaTypeType = "location"
|
||||
// StoryAreaTypeReactionType identifies a suggested reaction story area.
|
||||
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
|
||||
// StoryAreaTypeLinkType identifies a link story area.
|
||||
StoryAreaTypeLinkType StoryAreaTypeType = "link"
|
||||
// StoryAreaTypeWeatherType identifies a weather story area.
|
||||
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
|
||||
// StoryAreaTypeUniqueGiftType identifies a unique gift story area.
|
||||
StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift"
|
||||
)
|
||||
|
||||
|
||||
+18
-8
@@ -17,10 +17,14 @@ type Chat struct {
|
||||
type ChatType string
|
||||
|
||||
const (
|
||||
ChatTypePrivate ChatType = "private"
|
||||
ChatTypeGroup ChatType = "group"
|
||||
// ChatTypePrivate identifies a private chat.
|
||||
ChatTypePrivate ChatType = "private"
|
||||
// ChatTypeGroup identifies a basic group chat.
|
||||
ChatTypeGroup ChatType = "group"
|
||||
// ChatTypeSupergroup identifies a supergroup chat.
|
||||
ChatTypeSupergroup ChatType = "supergroup"
|
||||
ChatTypeChannel ChatType = "channel"
|
||||
// ChatTypeChannel identifies a channel chat.
|
||||
ChatTypeChannel ChatType = "channel"
|
||||
)
|
||||
|
||||
// ChatFullInfo contains full information about a chat.
|
||||
@@ -143,12 +147,18 @@ type ChatInviteLink struct {
|
||||
type ChatMemberStatusType string
|
||||
|
||||
const (
|
||||
ChatMemberStatusOwner ChatMemberStatusType = "owner"
|
||||
// ChatMemberStatusOwner identifies a chat owner.
|
||||
ChatMemberStatusOwner ChatMemberStatusType = "owner"
|
||||
// ChatMemberStatusAdministrator identifies a chat administrator.
|
||||
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
|
||||
ChatMemberStatusMember ChatMemberStatusType = "member"
|
||||
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
|
||||
ChatMemberStatusLeft ChatMemberStatusType = "left"
|
||||
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
|
||||
// ChatMemberStatusMember identifies a regular member.
|
||||
ChatMemberStatusMember ChatMemberStatusType = "member"
|
||||
// ChatMemberStatusRestricted identifies a restricted member.
|
||||
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
|
||||
// ChatMemberStatusLeft identifies a user who left the chat.
|
||||
ChatMemberStatusLeft ChatMemberStatusType = "left"
|
||||
// ChatMemberStatusBanned identifies a banned user.
|
||||
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
|
||||
)
|
||||
|
||||
// ChatMember contains information about one member of a chat.
|
||||
|
||||
@@ -2,7 +2,14 @@ package tgapi
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrRateLimit reports that a request exceeded the configured rate limiter.
|
||||
var ErrRateLimit = errors.New("rate limit exceeded")
|
||||
|
||||
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
|
||||
var ErrPoolUnexpected = errors.New("unexpected response from pool")
|
||||
|
||||
// ErrPoolQueueFull reports that the internal request queue is full.
|
||||
var ErrPoolQueueFull = errors.New("worker pool queue full")
|
||||
|
||||
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
|
||||
var ErrPoolStopped = errors.New("worker pool stopped")
|
||||
|
||||
+79
-45
@@ -45,9 +45,9 @@ type Message struct {
|
||||
|
||||
Text string `json:"text"`
|
||||
|
||||
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||
Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||
|
||||
Date int `json:"date"`
|
||||
EditDate int `json:"edit_date"`
|
||||
@@ -77,26 +77,46 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage }
|
||||
type MessageEntityType string
|
||||
|
||||
const (
|
||||
MessageEntityMention MessageEntityType = "mention"
|
||||
MessageEntityHashtag MessageEntityType = "hashtag"
|
||||
MessageEntityCashtag MessageEntityType = "cashtag"
|
||||
MessageEntityBotCommand MessageEntityType = "bot_command"
|
||||
MessageEntityUrl MessageEntityType = "url"
|
||||
MessageEntityEmail MessageEntityType = "email"
|
||||
MessageEntityPhoneNumber MessageEntityType = "phone_number"
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
MessageEntityItalic MessageEntityType = "italic"
|
||||
MessageEntityUnderline MessageEntityType = "underline"
|
||||
MessageEntityStrike MessageEntityType = "strikethrough"
|
||||
MessageEntitySpoiler MessageEntityType = "spoiler"
|
||||
MessageEntityBlockquote MessageEntityType = "blockquote"
|
||||
// MessageEntityMention identifies an @mention entity.
|
||||
MessageEntityMention MessageEntityType = "mention"
|
||||
// MessageEntityHashtag identifies a hashtag entity.
|
||||
MessageEntityHashtag MessageEntityType = "hashtag"
|
||||
// MessageEntityCashtag identifies a cashtag entity.
|
||||
MessageEntityCashtag MessageEntityType = "cashtag"
|
||||
// MessageEntityBotCommand identifies a bot command entity.
|
||||
MessageEntityBotCommand MessageEntityType = "bot_command"
|
||||
// MessageEntityUrl identifies a URL entity.
|
||||
MessageEntityUrl MessageEntityType = "url"
|
||||
// MessageEntityEmail identifies an email entity.
|
||||
MessageEntityEmail MessageEntityType = "email"
|
||||
// MessageEntityPhoneNumber identifies a phone number entity.
|
||||
MessageEntityPhoneNumber MessageEntityType = "phone_number"
|
||||
// MessageEntityBold identifies bold text.
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
// MessageEntityItalic identifies italic text.
|
||||
MessageEntityItalic MessageEntityType = "italic"
|
||||
// MessageEntityUnderline identifies underlined text.
|
||||
MessageEntityUnderline MessageEntityType = "underline"
|
||||
// MessageEntityStrike identifies strikethrough text.
|
||||
MessageEntityStrike MessageEntityType = "strikethrough"
|
||||
// MessageEntitySpoiler identifies spoiler text.
|
||||
MessageEntitySpoiler MessageEntityType = "spoiler"
|
||||
// MessageEntityBlockquote identifies a blockquote entity.
|
||||
MessageEntityBlockquote MessageEntityType = "blockquote"
|
||||
// MessageEntityExpandableBlockquote identifies an expandable blockquote entity.
|
||||
MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote"
|
||||
MessageEntityCode MessageEntityType = "code"
|
||||
MessageEntityPre MessageEntityType = "pre"
|
||||
MessageEntityTextLink MessageEntityType = "text_link"
|
||||
MessageEntityTextMention MessageEntityType = "text_mention"
|
||||
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
|
||||
MessageEntityDateTime MessageEntityType = "date_time"
|
||||
// MessageEntityCode identifies inline code.
|
||||
MessageEntityCode MessageEntityType = "code"
|
||||
// MessageEntityPre identifies a preformatted block.
|
||||
MessageEntityPre MessageEntityType = "pre"
|
||||
// MessageEntityTextLink identifies linked text.
|
||||
MessageEntityTextLink MessageEntityType = "text_link"
|
||||
// MessageEntityTextMention identifies a text mention.
|
||||
MessageEntityTextMention MessageEntityType = "text_mention"
|
||||
// MessageEntityCustomEmoji identifies a custom emoji entity.
|
||||
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
|
||||
// MessageEntityDateTime identifies a date-time entity.
|
||||
MessageEntityDateTime MessageEntityType = "date_time"
|
||||
)
|
||||
|
||||
// MessageEntity represents one special entity in a text message.
|
||||
@@ -121,12 +141,12 @@ type ReplyParameters struct {
|
||||
MessageID int `json:"message_id"`
|
||||
ChatID int64 `json:"chat_id,omitempty"`
|
||||
|
||||
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
|
||||
Quote string `json:"quote,omitempty"`
|
||||
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
|
||||
QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"`
|
||||
QuotePosition int `json:"quote_position,omitempty"`
|
||||
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
|
||||
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
|
||||
Quote string `json:"quote,omitempty"`
|
||||
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
|
||||
QuoteEntities []MessageEntity `json:"quote_entities,omitempty"`
|
||||
QuotePosition int `json:"quote_position,omitempty"`
|
||||
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
|
||||
}
|
||||
|
||||
// LinkPreviewOptions describes the options used for link preview generation.
|
||||
@@ -166,8 +186,11 @@ type InlineKeyboardMarkup struct {
|
||||
type KeyboardButtonStyle string
|
||||
|
||||
const (
|
||||
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
|
||||
// KeyboardButtonStyleDanger marks a destructive keyboard button.
|
||||
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
|
||||
// KeyboardButtonStyleSuccess marks a confirmatory keyboard button.
|
||||
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
|
||||
// KeyboardButtonStylePrimary marks a primary keyboard button.
|
||||
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
|
||||
)
|
||||
|
||||
@@ -255,32 +278,34 @@ type CallbackQuery struct {
|
||||
// InputPollOption contains information about one answer option in a poll to be sent.
|
||||
// See https://core.telegram.org/bots/api#inputpolloption
|
||||
type InputPollOption struct {
|
||||
Text string `json:"text"`
|
||||
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
|
||||
TextEntities []*MessageEntity `json:"text_entities,omitempty"`
|
||||
Text string `json:"text"`
|
||||
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
|
||||
TextEntities []MessageEntity `json:"text_entities,omitempty"`
|
||||
}
|
||||
|
||||
// PollType represents the type of a poll.
|
||||
type PollType string
|
||||
|
||||
const (
|
||||
// PollTypeRegular identifies a regular poll.
|
||||
PollTypeRegular PollType = "regular"
|
||||
PollTypeQuiz PollType = "quiz"
|
||||
// PollTypeQuiz identifies a quiz poll.
|
||||
PollTypeQuiz PollType = "quiz"
|
||||
)
|
||||
|
||||
// InputChecklistTask describes a task in a checklist.
|
||||
type InputChecklistTask struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
TextEntities []*MessageEntity `json:"text_entities,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
TextEntities []MessageEntity `json:"text_entities,omitempty"`
|
||||
}
|
||||
|
||||
// InputChecklist represents a checklist to be sent.
|
||||
type InputChecklist struct {
|
||||
Title string `json:"title"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
TitleEntities []*MessageEntity `json:"title_entities,omitempty"`
|
||||
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
|
||||
Tasks []InputChecklistTask `json:"tasks"`
|
||||
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
|
||||
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
|
||||
@@ -290,14 +315,23 @@ type InputChecklist struct {
|
||||
type ChatActionType string
|
||||
|
||||
const (
|
||||
ChatActionTyping ChatActionType = "typing"
|
||||
ChatActionUploadPhoto ChatActionType = "upload_photo"
|
||||
ChatActionUploadVideo ChatActionType = "upload_video"
|
||||
ChatActionUploadVoice ChatActionType = "upload_voice"
|
||||
ChatActionUploadDocument ChatActionType = "upload_document"
|
||||
ChatActionChooseSticker ChatActionType = "choose_sticker"
|
||||
ChatActionFindLocation ChatActionType = "find_location"
|
||||
// ChatActionTyping tells Telegram the bot is typing.
|
||||
ChatActionTyping ChatActionType = "typing"
|
||||
// ChatActionUploadPhoto tells Telegram the bot is uploading a photo.
|
||||
ChatActionUploadPhoto ChatActionType = "upload_photo"
|
||||
// ChatActionUploadVideo tells Telegram the bot is uploading a video.
|
||||
ChatActionUploadVideo ChatActionType = "upload_video"
|
||||
// ChatActionUploadVoice tells Telegram the bot is uploading a voice message.
|
||||
ChatActionUploadVoice ChatActionType = "upload_voice"
|
||||
// ChatActionUploadDocument tells Telegram the bot is uploading a document.
|
||||
ChatActionUploadDocument ChatActionType = "upload_document"
|
||||
// ChatActionChooseSticker tells Telegram the bot is choosing a sticker.
|
||||
ChatActionChooseSticker ChatActionType = "choose_sticker"
|
||||
// ChatActionFindLocation tells Telegram the bot is finding a location.
|
||||
ChatActionFindLocation ChatActionType = "find_location"
|
||||
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
|
||||
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
|
||||
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
|
||||
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
|
||||
)
|
||||
|
||||
|
||||
+31
-4
@@ -170,6 +170,7 @@ func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File,
|
||||
|
||||
// GetFileByLink downloads a file from Telegram's file server using the provided file link.
|
||||
// The link is usually obtained from File.FilePath.
|
||||
// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body.
|
||||
// See https://core.telegram.org/bots/api#file
|
||||
func (api *API) GetFileByLink(link string) ([]byte, error) {
|
||||
return api.getFileByLink(context.Background(), link)
|
||||
@@ -177,12 +178,38 @@ func (api *API) GetFileByLink(link string) ([]byte, error) {
|
||||
|
||||
// GetFileByLinkWithContext is the context-aware variant of GetFileByLink.
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// For large files, prefer OpenFileByLinkWithContext to stream the response body.
|
||||
// See https://core.telegram.org/bots/api#file
|
||||
func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) {
|
||||
return api.getFileByLink(ctx, link)
|
||||
}
|
||||
|
||||
// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server.
|
||||
// The caller must close the returned ReadCloser.
|
||||
// See https://core.telegram.org/bots/api#file
|
||||
func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) {
|
||||
return api.openFileByLink(context.Background(), link)
|
||||
}
|
||||
|
||||
// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink.
|
||||
// The caller must close the returned ReadCloser.
|
||||
// See https://core.telegram.org/bots/api#file
|
||||
func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) {
|
||||
return api.openFileByLink(ctx, link)
|
||||
}
|
||||
|
||||
func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) {
|
||||
body, err := api.openFileByLink(ctx, link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = body.Close()
|
||||
}()
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
|
||||
func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) {
|
||||
methodPrefix := ""
|
||||
if api.useTestServer {
|
||||
methodPrefix = "/test"
|
||||
@@ -199,15 +226,15 @@ func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
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)
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
@@ -44,6 +44,44 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(&http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("streamed payload")),
|
||||
}, nil
|
||||
}),
|
||||
}),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := api.OpenFileByLink("files/report.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFileByLink returned error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := body.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read body: %v", err)
|
||||
}
|
||||
if string(data) != "streamed payload" {
|
||||
t.Fatalf("unexpected payload: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
|
||||
+22
-62
@@ -5,44 +5,35 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// workerPool — приватная структура, управляющая пулом воркеров.
|
||||
// Внешний код не может создавать или напрямую взаимодействовать с этой структурой.
|
||||
// Используется только через экспортируемые методы newWorkerPool, start, stop, submit.
|
||||
type workerPool struct {
|
||||
taskCh chan requestEnvelope // канал для принятия задач (буферизованный)
|
||||
queueSize int // максимальный размер очереди
|
||||
workers int // количество воркеров (горутин)
|
||||
wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке
|
||||
quit chan struct{} // канал для сигнала остановки
|
||||
stopOnce sync.Once // гарантирует идемпотентную остановку пула
|
||||
started bool // флаг, указывающий, запущен ли пул
|
||||
stopped bool // флаг, указывающий, что пул остановлен
|
||||
startedMu sync.Mutex // мьютекс для безопасного доступа к started
|
||||
taskCh chan requestEnvelope
|
||||
queueSize int
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
quit chan struct{}
|
||||
stopOnce sync.Once
|
||||
started bool
|
||||
stopped bool
|
||||
startedMu sync.Mutex
|
||||
}
|
||||
|
||||
// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата.
|
||||
// Используется только внутри пакета для передачи задач воркерам.
|
||||
type requestEnvelope struct {
|
||||
ctx context.Context // контекст конкретной задачи
|
||||
doFunc func(context.Context) (any, error) // функция, выполняющая запрос
|
||||
resultCh chan requestResult // канал, через который воркер вернёт результат
|
||||
ctx context.Context
|
||||
doFunc func(context.Context) (any, error)
|
||||
resultCh chan requestResult
|
||||
}
|
||||
|
||||
// requestResult — приватная структура, представляющая результат выполнения задачи.
|
||||
// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult.
|
||||
type requestResult struct {
|
||||
value any // значение, возвращённое задачей
|
||||
err error // ошибка, если возникла
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди.
|
||||
// Это единственный способ создать workerPool — внешний код не может создать его напрямую.
|
||||
func newWorkerPool(workers int, queueSize int) *workerPool {
|
||||
if workers <= 0 {
|
||||
workers = 1 // защита от некорректных значений
|
||||
workers = 1
|
||||
}
|
||||
if queueSize <= 0 {
|
||||
queueSize = 100 // разумный дефолт
|
||||
queueSize = 100
|
||||
}
|
||||
|
||||
return &workerPool{
|
||||
@@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool {
|
||||
}
|
||||
}
|
||||
|
||||
// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди.
|
||||
// Метод идемпотентен: если пул уже запущен — ничего не делает.
|
||||
// Должен вызываться перед первым вызовом submit.
|
||||
func (p *workerPool) start() {
|
||||
p.startedMu.Lock()
|
||||
defer p.startedMu.Unlock()
|
||||
if p.started {
|
||||
return // уже запущен — ничего не делаем
|
||||
return
|
||||
}
|
||||
p.started = true
|
||||
|
||||
// Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле
|
||||
for i := 0; i < p.workers; i++ {
|
||||
p.wg.Add(1)
|
||||
go p.worker() // запускаем горутину
|
||||
go p.worker()
|
||||
}
|
||||
}
|
||||
|
||||
// stop останавливает пул воркеров.
|
||||
// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач.
|
||||
// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта.
|
||||
func (p *workerPool) stop() {
|
||||
p.stopOnce.Do(func() {
|
||||
p.startedMu.Lock()
|
||||
p.stopped = true
|
||||
p.started = false
|
||||
close(p.quit) // сигнал для всех воркеров — выйти из цикла
|
||||
close(p.quit)
|
||||
p.startedMu.Unlock()
|
||||
|
||||
p.wg.Wait() // ждём, пока все воркеры завершатся
|
||||
p.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат.
|
||||
// Если очередь переполнена — возвращает ErrPoolQueueFull.
|
||||
// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи.
|
||||
// Контекст используется для отмены задачи, если клиент отменил запрос до отправки.
|
||||
func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) {
|
||||
p.startedMu.Lock()
|
||||
if p.stopped || !p.started {
|
||||
@@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any,
|
||||
return nil, ErrPoolStopped
|
||||
}
|
||||
|
||||
// Проверяем, не превышена ли очередь
|
||||
if len(p.taskCh) >= p.queueSize {
|
||||
p.startedMu.Unlock()
|
||||
return nil, ErrPoolQueueFull
|
||||
}
|
||||
|
||||
// Создаём канал для результата — буферизованный, чтобы не блокировать воркера
|
||||
resultCh := make(chan requestResult, 1)
|
||||
|
||||
// Создаём обёртку задачи
|
||||
envelope := requestEnvelope{
|
||||
ctx: ctx,
|
||||
doFunc: do,
|
||||
resultCh: resultCh,
|
||||
}
|
||||
|
||||
// Пытаемся отправить задачу в очередь
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.startedMu.Unlock()
|
||||
// Клиент отменил операцию до отправки — возвращаем ошибку отмены
|
||||
return nil, ctx.Err()
|
||||
case p.taskCh <- envelope:
|
||||
p.startedMu.Unlock()
|
||||
// Успешно отправлено — возвращаем канал для чтения результата
|
||||
return resultCh, nil
|
||||
default:
|
||||
p.startedMu.Unlock()
|
||||
// Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай
|
||||
return nil, ErrPoolQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
// worker — приватная горутина, выполняющая задачи из очереди.
|
||||
// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки.
|
||||
// При получении задачи:
|
||||
// - вызывает doFunc с контекстом
|
||||
// - записывает результат в resultCh
|
||||
// - закрывает канал, чтобы клиент мог прочитать и завершить
|
||||
//
|
||||
// После закрытия quit-канала — воркер завершает работу.
|
||||
func (p *workerPool) worker() {
|
||||
defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины
|
||||
defer p.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.quit:
|
||||
// Получен сигнал остановки — дренируем очередь и выходим.
|
||||
// После stop() новые задачи не принимаются.
|
||||
// Drain queued work after stop. No new tasks are accepted.
|
||||
for {
|
||||
select {
|
||||
case envelope := <-p.taskCh:
|
||||
@@ -162,14 +126,10 @@ func (p *workerPool) worker() {
|
||||
}
|
||||
|
||||
func (p *workerPool) executeEnvelope(envelope requestEnvelope) {
|
||||
// Выполняем задачу с переданным контекстом (клиентский или общий)
|
||||
value, err := envelope.doFunc(envelope.ctx)
|
||||
|
||||
// Записываем результат в канал — не блокируем, т.к. буфер 1
|
||||
envelope.resultCh <- requestResult{
|
||||
value: value,
|
||||
err: err,
|
||||
}
|
||||
// Закрываем канал — клиент знает, что результат пришёл и больше не будет
|
||||
close(envelope.resultCh)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerPoolSubmitAfterStop(t *testing.T) {
|
||||
pool := newWorkerPool(1, 1)
|
||||
pool.start()
|
||||
pool.stop()
|
||||
|
||||
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
|
||||
return nil, nil
|
||||
}); !errors.Is(err, ErrPoolStopped) {
|
||||
t.Fatalf("expected ErrPoolStopped, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPoolQueueFull(t *testing.T) {
|
||||
pool := newWorkerPool(1, 1)
|
||||
pool.start()
|
||||
defer pool.stop()
|
||||
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return "first", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first submit returned error: %v", err)
|
||||
}
|
||||
<-started
|
||||
|
||||
secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
|
||||
return "second", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second submit returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
|
||||
return "third", nil
|
||||
}); !errors.Is(err, ErrPoolQueueFull) {
|
||||
t.Fatalf("expected ErrPoolQueueFull, got %v", err)
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
first := <-firstResult
|
||||
if first.err != nil || first.value != "first" {
|
||||
t.Fatalf("unexpected first result: %+v", first)
|
||||
}
|
||||
second := <-secondResult
|
||||
if second.err != nil || second.value != "second" {
|
||||
t.Fatalf("unexpected second result: %+v", second)
|
||||
}
|
||||
}
|
||||
+83
-40
@@ -6,6 +6,9 @@ import "encoding/json"
|
||||
type UpdateType string
|
||||
|
||||
const (
|
||||
// UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind.
|
||||
UpdateTypeUnknown UpdateType = "unknown"
|
||||
|
||||
// UpdateTypeMessage is a regular message update.
|
||||
UpdateTypeMessage UpdateType = "message"
|
||||
// UpdateTypeEditedMessage is an edited message update.
|
||||
@@ -27,8 +30,6 @@ const (
|
||||
UpdateTypeEditedBusinessMessage UpdateType = "edited_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"
|
||||
@@ -61,6 +62,8 @@ const (
|
||||
// Update represents an incoming update from Telegram.
|
||||
// See https://core.telegram.org/bots/api#update
|
||||
type Update struct {
|
||||
Type UpdateType `json:"-"`
|
||||
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
EditedMessage *Message `json:"edited_message,omitempty"`
|
||||
@@ -71,7 +74,6 @@ type Update struct {
|
||||
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"`
|
||||
|
||||
@@ -91,33 +93,72 @@ 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.
|
||||
// UnmarshalJSON decodes an update and derives its Type from the populated payload field.
|
||||
func (u *Update) UnmarshalJSON(data []byte) error {
|
||||
type alias Update
|
||||
var aux alias
|
||||
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))
|
||||
*u = Update(aux)
|
||||
|
||||
switch {
|
||||
case u.Message != nil:
|
||||
u.Type = UpdateTypeMessage
|
||||
case u.EditedMessage != nil:
|
||||
u.Type = UpdateTypeEditedMessage
|
||||
case u.ChannelPost != nil:
|
||||
u.Type = UpdateTypeChannelPost
|
||||
case u.EditedChannelPost != nil:
|
||||
u.Type = UpdateTypeEditedChannelPost
|
||||
|
||||
case u.BusinessConnection != nil:
|
||||
u.Type = UpdateTypeBusinessConnection
|
||||
case u.BusinessMessage != nil:
|
||||
u.Type = UpdateTypeBusinessMessage
|
||||
case u.EditedBusinessMessage != nil:
|
||||
u.Type = UpdateTypeEditedBusinessMessage
|
||||
case u.DeletedBusinessMessages != nil:
|
||||
u.Type = UpdateTypeDeletedBusinessMessages
|
||||
case u.MessageReaction != nil:
|
||||
u.Type = UpdateTypeMessageReaction
|
||||
case u.MessageReactionCount != nil:
|
||||
u.Type = UpdateTypeMessageReactionCount
|
||||
|
||||
case u.InlineQuery != nil:
|
||||
u.Type = UpdateTypeInlineQuery
|
||||
case u.ChosenInlineResult != nil:
|
||||
u.Type = UpdateTypeChosenInlineResult
|
||||
case u.CallbackQuery != nil:
|
||||
u.Type = UpdateTypeCallbackQuery
|
||||
case u.ShippingQuery != nil:
|
||||
u.Type = UpdateTypeShippingQuery
|
||||
case u.PreCheckoutQuery != nil:
|
||||
u.Type = UpdateTypePreCheckoutQuery
|
||||
case u.PurchasedPaidMedia != nil:
|
||||
u.Type = UpdateTypePurchasedPaidMedia
|
||||
|
||||
case u.Poll != nil:
|
||||
u.Type = UpdateTypePoll
|
||||
case u.PollAnswer != nil:
|
||||
u.Type = UpdateTypePollAnswer
|
||||
case u.MyChatMember != nil:
|
||||
u.Type = UpdateTypeMyChatMember
|
||||
case u.ChatMember != nil:
|
||||
u.Type = UpdateTypeChatMember
|
||||
case u.ChatJoinRequest != nil:
|
||||
u.Type = UpdateTypeChatJoinRequest
|
||||
case u.ChatBoost != nil:
|
||||
u.Type = UpdateTypeChatBoost
|
||||
case u.RemovedChatBoost != nil:
|
||||
u.Type = UpdateTypeRemovedChatBoost
|
||||
default:
|
||||
u.Type = UpdateTypeUnknown
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InlineQuery represents an incoming inline query.
|
||||
@@ -351,19 +392,19 @@ type GiftBackground struct {
|
||||
|
||||
// Gift represents a gift that can be sent.
|
||||
type Gift struct {
|
||||
ID string `json:"id"`
|
||||
Sticker Sticker `json:"sticker"`
|
||||
StarCount int `json:"star_count"`
|
||||
UpdateStarCount *int `json:"update_star_count,omitempty"`
|
||||
IsPremium *bool `json:"is_premium,omitempty"`
|
||||
HasColors *bool `json:"has_colors,omitempty"`
|
||||
TotalCount *int `json:"total_count,omitempty"`
|
||||
RemainingCount *int `json:"remaining_count,omitempty"`
|
||||
PersonalTotalCount *int `json:"personal_total_count,omitempty"`
|
||||
PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"`
|
||||
Background GiftBackground `json:"background,omitempty"`
|
||||
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
|
||||
PublisherChat *Chat `json:"publisher_chat,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Sticker Sticker `json:"sticker"`
|
||||
StarCount int `json:"star_count"`
|
||||
UpdateStarCount *int `json:"update_star_count,omitempty"`
|
||||
IsPremium *bool `json:"is_premium,omitempty"`
|
||||
HasColors *bool `json:"has_colors,omitempty"`
|
||||
TotalCount *int `json:"total_count,omitempty"`
|
||||
RemainingCount *int `json:"remaining_count,omitempty"`
|
||||
PersonalTotalCount *int `json:"personal_total_count,omitempty"`
|
||||
PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"`
|
||||
Background *GiftBackground `json:"background,omitempty"`
|
||||
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
|
||||
PublisherChat *Chat `json:"publisher_chat,omitempty"`
|
||||
}
|
||||
|
||||
// Gifts represents a list of gifts.
|
||||
@@ -375,8 +416,10 @@ type Gifts struct {
|
||||
type OwnedGiftType string
|
||||
|
||||
const (
|
||||
// OwnedGiftRegularType identifies a regular owned gift.
|
||||
OwnedGiftRegularType OwnedGiftType = "regular"
|
||||
OwnedGiftUniqueType OwnedGiftType = "unique"
|
||||
// OwnedGiftUniqueType identifies a unique owned gift.
|
||||
OwnedGiftUniqueType OwnedGiftType = "unique"
|
||||
)
|
||||
|
||||
// OwnedGift represents a gift owned by a user or chat.
|
||||
@@ -388,7 +431,7 @@ type OwnedGift struct {
|
||||
|
||||
// Fields specific to "regular" type
|
||||
Gift Gift `json:"gift"`
|
||||
SenderUser User `json:"sender_user,omitempty"`
|
||||
SenderUser *User `json:"sender_user,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
IsPrivate *bool `json:"is_private,omitempty"`
|
||||
|
||||
+58
-33
@@ -6,41 +6,66 @@ import (
|
||||
"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)
|
||||
func TestUpdateUnmarshalSetsType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want UpdateType
|
||||
}{
|
||||
{
|
||||
name: "deleted business messages",
|
||||
body: `{
|
||||
"update_id": 1,
|
||||
"deleted_business_messages": {
|
||||
"business_connection_id": "conn",
|
||||
"chat": {"id": 42, "type": "private"},
|
||||
"message_ids": [3, 5]
|
||||
}
|
||||
}`,
|
||||
want: UpdateTypeDeletedBusinessMessages,
|
||||
},
|
||||
{
|
||||
name: "callback query",
|
||||
body: `{
|
||||
"update_id": 2,
|
||||
"callback_query": {
|
||||
"id": "cb",
|
||||
"from": {"id": 1, "is_bot": false, "first_name": "Test"},
|
||||
"chat_instance": "instance",
|
||||
"data": "payload"
|
||||
}
|
||||
}`,
|
||||
want: UpdateTypeCallbackQuery,
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
body: `{"update_id":3}`,
|
||||
want: UpdateTypeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var update Update
|
||||
if err := json.Unmarshal([]byte(tt.body), &update); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if update.Type != tt.want {
|
||||
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
|
||||
func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) {
|
||||
update := Update{
|
||||
UpdateID: 1,
|
||||
DeletedBusinessMessage: &BusinessMessagesDeleted{
|
||||
BusinessConnectionID: "conn",
|
||||
Chat: Chat{ID: 42, Type: string(ChatTypePrivate)},
|
||||
MessageIDs: []int{7},
|
||||
Type: UpdateTypeCallbackQuery,
|
||||
CallbackQuery: &CallbackQuery{
|
||||
ID: "cb",
|
||||
From: User{ID: 1, FirstName: "Test"},
|
||||
ChatInstance: "instance",
|
||||
Data: "payload",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,11 +75,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
if strings.Contains(got, `"type"`) {
|
||||
t.Fatalf("unexpected synthetic type field, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +88,7 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
|
||||
if update.ShippingQuery != nil {
|
||||
t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery)
|
||||
}
|
||||
if update.Type != UpdateTypeUnknown {
|
||||
t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -82,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() }
|
||||
// See https://core.telegram.org/bots/api
|
||||
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
|
||||
|
||||
// UploaderRequest is a multipart file upload request to the Telegram API.
|
||||
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one.
|
||||
// UploaderRequest is a low-level multipart upload request wrapper.
|
||||
//
|
||||
// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest
|
||||
// is intended for advanced use cases where callers manage the method name, files,
|
||||
// and request/response types themselves. In that sense it is an unsafe escape
|
||||
// hatch compared with the typed uploader API.
|
||||
type UploaderRequest[R, P any] struct {
|
||||
method string
|
||||
files []UploaderFile
|
||||
@@ -91,16 +95,17 @@ type UploaderRequest[R, P any] struct {
|
||||
chatId int64
|
||||
}
|
||||
|
||||
// NewUploaderRequest creates a new multipart upload request with no associated chat ID.
|
||||
// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
|
||||
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
|
||||
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
|
||||
}
|
||||
|
||||
// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID.
|
||||
// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
|
||||
// The chat ID is used for per-chat rate limiting.
|
||||
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
|
||||
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
|
||||
}
|
||||
|
||||
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
|
||||
var zero R
|
||||
|
||||
@@ -205,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
|
||||
return r.DoWithContext(context.Background(), up)
|
||||
}
|
||||
|
||||
// prepareMultipart builds a multipart form body from the given files and params.
|
||||
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
|
||||
// Internal helper that builds a finalized multipart body from files and params.
|
||||
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
w := multipart.NewWriter(buf)
|
||||
@@ -239,8 +243,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
|
||||
return buf, w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// uploaderTypeByExt infers the Telegram upload field name from a file extension.
|
||||
// Falls back to UploaderDocumentType for unrecognized extensions.
|
||||
// Internal helper that infers an upload field name from a file extension.
|
||||
func uploaderTypeByExt(filename string) UploaderFileType {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
|
||||
Reference in New Issue
Block a user