Compare commits

..

8 Commits

Author SHA1 Message Date
lukaszraczylo 931ea7ebe6 chore(api): regenerate from Telegram Bot API v10.1 2026-06-11 23:05:01 +00:00
lukaszraczylo 140ea13bde fix(genapi): scope method-param enums per method, dedupe case-colliding enum consts
Bot API update added RichBlockListItem.type values (a/A/i/I/1) that
Pascal-case to identical const idents, and answerGuestQuery's non-enum
'result' param picked up answerChatJoinRequestQuery's Result enum via
the shared parent="" enum-plan key.

- key method params as method:<name> in the enum plan byField map
- enumDecl.ConstName resolves case collisions with Lower/Upper prefix
- regenerate api/ from 2026-06-11 snapshot
2026-06-12 00:03:45 +01:00
github-actions[bot] 0731f10907 chore(api): regenerate from Telegram Bot API v10.0 (#7)
Co-authored-by: lukaszraczylo <2182556+lukaszraczylo@users.noreply.github.com>
2026-06-01 13:15:45 +01:00
lukaszraczylo 6685414374 fix(ci): regen docs + bump x/net to v0.55.0
- regen docs/reference/client.md after the Version docstring rewrite
  in the previous SendForModule commit (gomarkdoc output drifted).
- bump golang.org/x/net 0.54.0 -> 0.55.0 for the govulncheck fixes
  (GO-2026-5027/5028/5029/5030 — html.Parse XSS / DoS chain reachable
  from cmd/scrape).
2026-05-22 23:44:24 +01:00
lukaszraczylo 404411b20c feat(telemetry): switch to SendForModule, drop hand-bumped Version
Replaces the hand-bumped client.Version="0.7.11" with
telemetry.SendForModule which reads the actual module version from
runtime/debug.BuildInfo.Deps at the consumer's build time. This
means future releases (v0.7.12, v0.8.0, …) will be reported by the
running bot WITHOUT requiring a manual edit of client/version.go on
every tag — consumers just `go get -u` and rebuild.

client.Version is preserved as a fallback for replace directives /
detached `go run`, and remains overridable via -ldflags.

Bumps oss-telemetry to v0.2.1.
2026-05-22 23:35:22 +01:00
lukaszraczylo 8d85e61da5 docs: collapse telemetry section to link upstream docs
Replaces the duplicated opt-out table with a link to the canonical
oss-telemetry README section. Keeps the project-specific env var name
and the local source pointer; removes drift risk for the rest.
2026-05-21 04:03:55 +01:00
lukaszraczylo 609c4ce649 feat: anonymous adoption telemetry on first client.New
Sends one fire-and-forget POST per process the first time a consumer
constructs a Bot via client.New. Helps track real-world adoption and
version spread of the library. No identifiers, no API contents.

Implementation:
- new client/version.go: exported Version var (currently 0.7.11)
- new client/telemetry.go: sync.Once gate + telemetry.Send call
- client.go New(): single fireTelemetryOnce() line at function entry
- client/telemetry_test.go: TestMain disables outgoing pings during the
  library's own test suite

README §Telemetry documents the payload, the opt-out env vars, and links
to the upstream oss-telemetry source so consumers can audit what is sent.

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  GO_TELEGRAM_DISABLE_TELEMETRY=1
  osstelemetry.Disable() before the first client.New
2026-05-21 03:59:07 +01:00
lukaszraczylo d39be13822 chore(ci): bump remaining Node 20 actions (#6)
actions/cache v4 -> v5 (Node 24 runtime; no API change)
actions/upload-artifact v4 -> v7 (ESM + Node 24; existing name/path usage unaffected)

Clears the last Node 20 deprecation warnings surfaced during the
release run in d6ecbde.
2026-05-20 23:39:16 +01:00
24 changed files with 49163 additions and 812 deletions
+8 -8
View File
@@ -30,7 +30,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -46,7 +46,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -63,7 +63,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -80,7 +80,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -103,7 +103,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -112,7 +112,7 @@ jobs:
- run: go test -race -coverprofile=coverage.out ./...
- name: Build all examples
run: go build ./examples/...
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: coverage
path: coverage.out
@@ -125,7 +125,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
@@ -146,7 +146,7 @@ jobs:
with:
go-version: '1.25.x'
check-latest: true
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
+14
View File
@@ -371,6 +371,20 @@ bot := client.New("token", client.WithHTTPClient(fakeDoer{
The library's own generated test suite (`api/methods_gen_test.go`) covers 176 methods × 8 scenarios each: Success, APIError, NetworkError, ParseError, ContextCanceled, MissingRequiredFields, Forbidden, ServerError.
## Telemetry
On the **first call to `client.New`** in a process, this library sends a
single anonymous adoption ping — project name, version, timestamp; no
identifiers, no message contents, no API call metadata. Fire-and-forget
with a 2-second timeout; cannot block `New` or panic.
Local source: [`client/telemetry.go`](client/telemetry.go). Upstream
implementation, exact wire format, and full opt-out documentation:
**[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `GO_TELEGRAM_DISABLE_TELEMETRY=1`.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
+91
View File
@@ -158,6 +158,7 @@ type InputPollOptionMediaType string
const (
InputPollOptionMediaTypeAnimation InputPollOptionMediaType = "animation"
InputPollOptionMediaTypeLink InputPollOptionMediaType = "link"
InputPollOptionMediaTypeLivePhoto InputPollOptionMediaType = "live_photo"
InputPollOptionMediaTypeLocation InputPollOptionMediaType = "location"
InputPollOptionMediaTypePhoto InputPollOptionMediaType = "photo"
@@ -357,6 +358,14 @@ const (
RefundedPaymentCurrencyXTR RefundedPaymentCurrency = "XTR"
)
type Result string
const (
ResultApprove Result = "approve"
ResultDecline Result = "decline"
ResultQueue Result = "queue"
)
type RevenueWithdrawalStateKind string
const (
@@ -365,6 +374,88 @@ const (
RevenueWithdrawalStateKindFailed RevenueWithdrawalStateKind = "failed"
)
type RichBlockListItemType string
const (
RichBlockListItemTypeLowerA RichBlockListItemType = "a"
RichBlockListItemTypeUpperA RichBlockListItemType = "A"
RichBlockListItemTypeLowerI RichBlockListItemType = "i"
RichBlockListItemTypeUpperI RichBlockListItemType = "I"
RichBlockListItemType1 RichBlockListItemType = "1"
)
type RichBlockTableCellAlign string
const (
RichBlockTableCellAlignLeft RichBlockTableCellAlign = "left"
RichBlockTableCellAlignCenter RichBlockTableCellAlign = "center"
RichBlockTableCellAlignRight RichBlockTableCellAlign = "right"
)
type RichBlockTableCellValign string
const (
RichBlockTableCellValignTop RichBlockTableCellValign = "top"
RichBlockTableCellValignMiddle RichBlockTableCellValign = "middle"
RichBlockTableCellValignBottom RichBlockTableCellValign = "bottom"
)
type RichBlockType string
const (
RichBlockTypeParagraph RichBlockType = "paragraph"
RichBlockTypeHeading RichBlockType = "heading"
RichBlockTypePre RichBlockType = "pre"
RichBlockTypeFooter RichBlockType = "footer"
RichBlockTypeDivider RichBlockType = "divider"
RichBlockTypeMathematicalExpression RichBlockType = "mathematical_expression"
RichBlockTypeAnchor RichBlockType = "anchor"
RichBlockTypeList RichBlockType = "list"
RichBlockTypeBlockquote RichBlockType = "blockquote"
RichBlockTypePullquote RichBlockType = "pullquote"
RichBlockTypeCollage RichBlockType = "collage"
RichBlockTypeSlideshow RichBlockType = "slideshow"
RichBlockTypeTable RichBlockType = "table"
RichBlockTypeDetails RichBlockType = "details"
RichBlockTypeMap RichBlockType = "map"
RichBlockTypeAnimation RichBlockType = "animation"
RichBlockTypeAudio RichBlockType = "audio"
RichBlockTypePhoto RichBlockType = "photo"
RichBlockTypeVideo RichBlockType = "video"
RichBlockTypeVoiceNote RichBlockType = "voice_note"
RichBlockTypeThinking RichBlockType = "thinking"
)
type RichTextType string
const (
RichTextTypeBold RichTextType = "bold"
RichTextTypeItalic RichTextType = "italic"
RichTextTypeUnderline RichTextType = "underline"
RichTextTypeStrikethrough RichTextType = "strikethrough"
RichTextTypeSpoiler RichTextType = "spoiler"
RichTextTypeDateTime RichTextType = "date_time"
RichTextTypeTextMention RichTextType = "text_mention"
RichTextTypeSubscript RichTextType = "subscript"
RichTextTypeSuperscript RichTextType = "superscript"
RichTextTypeMarked RichTextType = "marked"
RichTextTypeCode RichTextType = "code"
RichTextTypeCustomEmoji RichTextType = "custom_emoji"
RichTextTypeMathematicalExpression RichTextType = "mathematical_expression"
RichTextTypeURL RichTextType = "url"
RichTextTypeEmailAddress RichTextType = "email_address"
RichTextTypePhoneNumber RichTextType = "phone_number"
RichTextTypeBankCardNumber RichTextType = "bank_card_number"
RichTextTypeMention RichTextType = "mention"
RichTextTypeHashtag RichTextType = "hashtag"
RichTextTypeCashtag RichTextType = "cashtag"
RichTextTypeBotCommand RichTextType = "bot_command"
RichTextTypeAnchor RichTextType = "anchor"
RichTextTypeAnchorLink RichTextType = "anchor_link"
RichTextTypeReference RichTextType = "reference"
RichTextTypeReferenceLink RichTextType = "reference_link"
)
type StickerType string
const (
+100 -6
View File
@@ -2483,6 +2483,40 @@ func DeclineChatJoinRequest(ctx context.Context, b *client.Bot, p *DeclineChatJo
return client.Call[*DeclineChatJoinRequestParams, bool](ctx, b, "declineChatJoinRequest", p)
}
// AnswerChatJoinRequestQueryParams is the parameter set for AnswerChatJoinRequestQuery.
//
// Use this method to process a received chat join request query. Returns True on success.
type AnswerChatJoinRequestQueryParams struct {
// Unique identifier of the join request query
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
// Result of the query. Must be either “approve” to allow the user to join the chat, “decline” to disallow the user to join the chat, or “queue” to leave the decision to other administrators.
Result Result `json:"result"`
}
// AnswerChatJoinRequestQuery calls the answerChatJoinRequestQuery Telegram Bot API method.
//
// Use this method to process a received chat join request query. Returns True on success.
func AnswerChatJoinRequestQuery(ctx context.Context, b *client.Bot, p *AnswerChatJoinRequestQueryParams) (bool, error) {
return client.Call[*AnswerChatJoinRequestQueryParams, bool](ctx, b, "answerChatJoinRequestQuery", p)
}
// SendChatJoinRequestWebAppParams is the parameter set for SendChatJoinRequestWebApp.
//
// Use this method to process a received chat join request query by showing a Mini App to the user before deciding the outcome. Returns True on success.
type SendChatJoinRequestWebAppParams struct {
// Unique identifier of the join request query
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
// The URL of the Mini App to be opened
WebAppURL string `json:"web_app_url"`
}
// SendChatJoinRequestWebApp calls the sendChatJoinRequestWebApp Telegram Bot API method.
//
// Use this method to process a received chat join request query by showing a Mini App to the user before deciding the outcome. Returns True on success.
func SendChatJoinRequestWebApp(ctx context.Context, b *client.Bot, p *SendChatJoinRequestWebAppParams) (bool, error) {
return client.Call[*SendChatJoinRequestWebAppParams, bool](ctx, b, "sendChatJoinRequestWebApp", p)
}
// SetChatPhotoParams is the parameter set for SetChatPhoto.
//
// Use this method to set a new profile photo for the chat. Photos can't be changed for private chats. The bot must be an administrator in the chat for this to work and must have the appropriate administrator rights. Returns True on success.
@@ -4004,7 +4038,7 @@ func SavePreparedKeyboardButton(ctx context.Context, b *client.Bot, p *SavePrepa
// EditMessageTextParams is the parameter set for EditMessageText.
//
// Use this method to edit text and game messages. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
// Use this method to edit text, rich and game messages. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
type EditMessageTextParams struct {
// Unique identifier of the business connection on behalf of which the message to be edited was sent
BusinessConnectionID string `json:"business_connection_id,omitempty"`
@@ -4014,21 +4048,23 @@ type EditMessageTextParams struct {
MessageID *int64 `json:"message_id,omitempty"`
// Required if chat_id and message_id are not specified. Identifier of the inline message.
InlineMessageID string `json:"inline_message_id,omitempty"`
// New text of the message, 1-4096 characters after entities parsing
Text string `json:"text"`
// New text of the message, 1-4096 characters after entity parsing; required if rich_message isn't specified
Text string `json:"text,omitempty"`
// Mode for parsing entities in the message text. See formatting options for more details.
ParseMode ParseMode `json:"parse_mode,omitempty"`
// A JSON-serialized list of special entities that appear in message text, which can be specified instead of parse_mode
Entities []MessageEntity `json:"entities,omitempty"`
// Link preview generation options for the message
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
// New rich content of the message; required if text isn't specified
RichMessage *InputRichMessage `json:"rich_message,omitempty"`
// A JSON-serialized object for an inline keyboard
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
}
// EditMessageText calls the editMessageText Telegram Bot API method.
//
// Use this method to edit text and game messages. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
// Use this method to edit text, rich and game messages. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
func EditMessageText(ctx context.Context, b *client.Bot, p *EditMessageTextParams) (*MessageOrBool, error) {
return client.Call[*EditMessageTextParams, *MessageOrBool](ctx, b, "editMessageText", p)
}
@@ -4066,7 +4102,7 @@ func EditMessageCaption(ctx context.Context, b *client.Bot, p *EditMessageCaptio
// EditMessageMediaParams is the parameter set for EditMessageMedia.
//
// Use this method to edit animation, audio, document, live photo, photo, or video messages, or to add media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only to a document for document albums and to a photo, a live photo, or a video otherwise. When an inline message is edited, a new file can't be uploaded; use a previously uploaded file via its file_id or specify a URL. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
// Use this method to edit animation, audio, document, live photo, photo, or video messages, or to replace a text or a rich message with a media. If a message is part of a message album, then it can be edited only to an audio for audio albums, only to a document for document albums and to a photo, a live photo, or a video otherwise. When an inline message is edited, a new file can't be uploaded; use a previously uploaded file via its file_id or specify a URL. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
type EditMessageMediaParams struct {
// Unique identifier of the business connection on behalf of which the message to be edited was sent
BusinessConnectionID string `json:"business_connection_id,omitempty"`
@@ -4121,7 +4157,7 @@ func (p *EditMessageMediaParams) MultipartFiles() []client.MultipartFile {
// EditMessageMedia calls the editMessageMedia Telegram Bot API method.
//
// Use this method to edit animation, audio, document, live photo, photo, or video messages, or to add media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only to a document for document albums and to a photo, a live photo, or a video otherwise. When an inline message is edited, a new file can't be uploaded; use a previously uploaded file via its file_id or specify a URL. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
// Use this method to edit animation, audio, document, live photo, photo, or video messages, or to replace a text or a rich message with a media. If a message is part of a message album, then it can be edited only to an audio for audio albums, only to a document for document albums and to a photo, a live photo, or a video otherwise. When an inline message is edited, a new file can't be uploaded; use a previously uploaded file via its file_id or specify a URL. On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. Note that business messages that were not sent by the bot and do not contain an inline keyboard can only be edited within 48 hours from the time they were sent.
func EditMessageMedia(ctx context.Context, b *client.Bot, p *EditMessageMediaParams) (*MessageOrBool, error) {
return client.Call[*EditMessageMediaParams, *MessageOrBool](ctx, b, "editMessageMedia", p)
}
@@ -4795,6 +4831,64 @@ func DeleteStickerSet(ctx context.Context, b *client.Bot, p *DeleteStickerSetPar
return client.Call[*DeleteStickerSetParams, bool](ctx, b, "deleteStickerSet", p)
}
// SendRichMessageParams is the parameter set for SendRichMessage.
//
// Use this method to send rich messages. If the message contains a block with a media element, then the bot must have the right to send the media to the chat. On success, the sent Message is returned.
type SendRichMessageParams struct {
// Unique identifier of the business connection on behalf of which the message will be sent
BusinessConnectionID string `json:"business_connection_id,omitempty"`
// Unique identifier for the target chat or username of the target bot, supergroup or channel in the format @username
ChatID ChatID `json:"chat_id"`
// Unique identifier for the target message thread (topic) of a forum; for forum supergroups and private chats of bots with forum topic mode enabled only
MessageThreadID *int64 `json:"message_thread_id,omitempty"`
// Identifier of the direct messages topic to which the message will be sent; required if the message is sent to a direct messages chat
DirectMessagesTopicID *int64 `json:"direct_messages_topic_id,omitempty"`
// The message to be sent
RichMessage InputRichMessage `json:"rich_message"`
// Sends the message silently. Users will receive a notification with no sound.
DisableNotification *bool `json:"disable_notification,omitempty"`
// Protects the contents of the sent message from forwarding and saving
ProtectContent *bool `json:"protect_content,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.
AllowPaidBroadcast *bool `json:"allow_paid_broadcast,omitempty"`
// Unique identifier of the message effect to be added to the message; for private chats only
MessageEffectID string `json:"message_effect_id,omitempty"`
// A JSON-serialized object containing the parameters of the suggested post to send; for direct messages chats only. If the message is sent as a reply to another suggested post, then that suggested post is automatically declined.
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
// Description of the message to reply to
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
// Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove a reply keyboard or to force a reply from the user.
ReplyMarkup any `json:"reply_markup,omitempty"`
}
// SendRichMessage calls the sendRichMessage Telegram Bot API method.
//
// Use this method to send rich messages. If the message contains a block with a media element, then the bot must have the right to send the media to the chat. On success, the sent Message is returned.
func SendRichMessage(ctx context.Context, b *client.Bot, p *SendRichMessageParams) (*Message, error) {
return client.Call[*SendRichMessageParams, *Message](ctx, b, "sendRichMessage", p)
}
// SendRichMessageDraftParams is the parameter set for SendRichMessageDraft.
//
// Use this method to stream a partial rich message to a user while the message is being generated. Note that the streamed draft is ephemeral and acts as a temporary 30-second preview - once the output is finalized, you must call sendRichMessage with the complete message to persist it in the user's chat. Returns True on success.
type SendRichMessageDraftParams struct {
// Unique identifier for the target private chat
ChatID int64 `json:"chat_id"`
// Unique identifier for the target message thread
MessageThreadID *int64 `json:"message_thread_id,omitempty"`
// Unique identifier of the message draft; must be non-zero. Changes to drafts with the same identifier are animated.
DraftID int64 `json:"draft_id"`
// The partial message to be streamed
RichMessage InputRichMessage `json:"rich_message"`
}
// SendRichMessageDraft calls the sendRichMessageDraft Telegram Bot API method.
//
// Use this method to stream a partial rich message to a user while the message is being generated. Note that the streamed draft is ephemeral and acts as a temporary 30-second preview - once the output is finalized, you must call sendRichMessage with the complete message to persist it in the user's chat. Returns True on success.
func SendRichMessageDraft(ctx context.Context, b *client.Bot, p *SendRichMessageDraftParams) (bool, error) {
return client.Call[*SendRichMessageDraftParams, bool](ctx, b, "sendRichMessageDraft", p)
}
// AnswerInlineQueryParams is the parameter set for AnswerInlineQuery.
//
// Use this method to send answers to an inline query. On success, True is returned.No more than 50 results per query are allowed.
+590 -21
View File
@@ -7427,6 +7427,294 @@ func Test_DeclineChatJoinRequest_ServerError(t *testing.T) {
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_AnswerChatJoinRequestQuery_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/answerChatJoinRequestQuery")
})).Return(genTestResp(200, `{"ok":true,"result":true}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.NoError(t, err)
}
func Test_AnswerChatJoinRequestQuery_APIError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 429, ae.Code)
require.True(t, ae.IsRetryable())
}
func Test_AnswerChatJoinRequestQuery_NetworkError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.Error(t, err)
var ne *client.NetworkError
require.ErrorAs(t, err, &ne)
}
func Test_AnswerChatJoinRequestQuery_ParseError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.Error(t, err)
var pe *client.ParseError
require.ErrorAs(t, err, &pe)
}
func Test_AnswerChatJoinRequestQuery_ContextCanceled(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(ctx, bot, params)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
}
// Test_AnswerChatJoinRequestQuery_MissingRequiredFields exercises Telegram's server-side
// validation: when a required field is omitted, Telegram returns 400 with
// a description like "Bad Request: <field> is empty". The library must
// surface this as *APIError with the ErrBadRequest sentinel.
func Test_AnswerChatJoinRequestQuery_MissingRequiredFields(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":400,"description":"Bad Request: chat_id is empty"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Send a Params with all required fields zeroed — simulates a caller
// that forgot to populate them. The bot library marshals as-is and
// surfaces Telegram's 400 reply.
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, &AnswerChatJoinRequestQueryParams{})
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 400, ae.Code)
require.True(t, errors.Is(err, client.ErrBadRequest))
require.False(t, ae.IsRetryable())
}
// Test_AnswerChatJoinRequestQuery_Forbidden exercises the 403 path (bot blocked by user,
// removed from chat, etc.). The library must surface the ErrForbidden
// sentinel.
func Test_AnswerChatJoinRequestQuery_Forbidden(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 403, ae.Code)
require.True(t, errors.Is(err, client.ErrForbidden))
require.False(t, ae.IsRetryable())
}
// Test_AnswerChatJoinRequestQuery_ServerError exercises the 5xx path. The library must
// classify these as retryable so RetryDoer / user retry logic kicks in.
func Test_AnswerChatJoinRequestQuery_ServerError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &AnswerChatJoinRequestQueryParams{
ChatJoinRequestQueryID: "test_value",
Result: ResultApprove,
}
_, err := AnswerChatJoinRequestQuery(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 500, ae.Code)
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_SendChatJoinRequestWebApp_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/sendChatJoinRequestWebApp")
})).Return(genTestResp(200, `{"ok":true,"result":true}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.NoError(t, err)
}
func Test_SendChatJoinRequestWebApp_APIError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 429, ae.Code)
require.True(t, ae.IsRetryable())
}
func Test_SendChatJoinRequestWebApp_NetworkError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.Error(t, err)
var ne *client.NetworkError
require.ErrorAs(t, err, &ne)
}
func Test_SendChatJoinRequestWebApp_ParseError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.Error(t, err)
var pe *client.ParseError
require.ErrorAs(t, err, &pe)
}
func Test_SendChatJoinRequestWebApp_ContextCanceled(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(ctx, bot, params)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
}
// Test_SendChatJoinRequestWebApp_MissingRequiredFields exercises Telegram's server-side
// validation: when a required field is omitted, Telegram returns 400 with
// a description like "Bad Request: <field> is empty". The library must
// surface this as *APIError with the ErrBadRequest sentinel.
func Test_SendChatJoinRequestWebApp_MissingRequiredFields(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":400,"description":"Bad Request: chat_id is empty"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Send a Params with all required fields zeroed — simulates a caller
// that forgot to populate them. The bot library marshals as-is and
// surfaces Telegram's 400 reply.
_, err := SendChatJoinRequestWebApp(context.Background(), bot, &SendChatJoinRequestWebAppParams{})
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 400, ae.Code)
require.True(t, errors.Is(err, client.ErrBadRequest))
require.False(t, ae.IsRetryable())
}
// Test_SendChatJoinRequestWebApp_Forbidden exercises the 403 path (bot blocked by user,
// removed from chat, etc.). The library must surface the ErrForbidden
// sentinel.
func Test_SendChatJoinRequestWebApp_Forbidden(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 403, ae.Code)
require.True(t, errors.Is(err, client.ErrForbidden))
require.False(t, ae.IsRetryable())
}
// Test_SendChatJoinRequestWebApp_ServerError exercises the 5xx path. The library must
// classify these as retryable so RetryDoer / user retry logic kicks in.
func Test_SendChatJoinRequestWebApp_ServerError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendChatJoinRequestWebAppParams{
ChatJoinRequestQueryID: "test_value",
WebAppURL: "test_value",
}
_, err := SendChatJoinRequestWebApp(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 500, ae.Code)
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_SetChatPhoto_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
@@ -18587,9 +18875,7 @@ func Test_EditMessageText_Success(t *testing.T) {
})).Return(genTestResp(200, `{"ok":true,"result":true}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.NoError(t, err)
}
@@ -18600,9 +18886,7 @@ func Test_EditMessageText_APIError(t *testing.T) {
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
@@ -18616,9 +18900,7 @@ func Test_EditMessageText_NetworkError(t *testing.T) {
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.Error(t, err)
var ne *client.NetworkError
@@ -18630,9 +18912,7 @@ func Test_EditMessageText_ParseError(t *testing.T) {
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.Error(t, err)
var pe *client.ParseError
@@ -18647,9 +18927,7 @@ func Test_EditMessageText_ContextCanceled(t *testing.T) {
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(ctx, bot, params)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
@@ -18686,9 +18964,7 @@ func Test_EditMessageText_Forbidden(t *testing.T) {
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
@@ -18706,9 +18982,7 @@ func Test_EditMessageText_ServerError(t *testing.T) {
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &EditMessageTextParams{
Text: "test_value",
}
params := &EditMessageTextParams{}
_, err := EditMessageText(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
@@ -22830,6 +23104,301 @@ func Test_DeleteStickerSet_ServerError(t *testing.T) {
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_SendRichMessage_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/sendRichMessage")
})).Return(genTestResp(200, `{"ok":true,"result":{}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.NoError(t, err)
}
func Test_SendRichMessage_APIError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 429, ae.Code)
require.True(t, ae.IsRetryable())
}
func Test_SendRichMessage_NetworkError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.Error(t, err)
var ne *client.NetworkError
require.ErrorAs(t, err, &ne)
}
func Test_SendRichMessage_ParseError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.Error(t, err)
var pe *client.ParseError
require.ErrorAs(t, err, &pe)
}
func Test_SendRichMessage_ContextCanceled(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(ctx, bot, params)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
}
// Test_SendRichMessage_MissingRequiredFields exercises Telegram's server-side
// validation: when a required field is omitted, Telegram returns 400 with
// a description like "Bad Request: <field> is empty". The library must
// surface this as *APIError with the ErrBadRequest sentinel.
func Test_SendRichMessage_MissingRequiredFields(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":400,"description":"Bad Request: chat_id is empty"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Send a Params with all required fields zeroed — simulates a caller
// that forgot to populate them. The bot library marshals as-is and
// surfaces Telegram's 400 reply.
_, err := SendRichMessage(context.Background(), bot, &SendRichMessageParams{})
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 400, ae.Code)
require.True(t, errors.Is(err, client.ErrBadRequest))
require.False(t, ae.IsRetryable())
}
// Test_SendRichMessage_Forbidden exercises the 403 path (bot blocked by user,
// removed from chat, etc.). The library must surface the ErrForbidden
// sentinel.
func Test_SendRichMessage_Forbidden(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 403, ae.Code)
require.True(t, errors.Is(err, client.ErrForbidden))
require.False(t, ae.IsRetryable())
}
// Test_SendRichMessage_ServerError exercises the 5xx path. The library must
// classify these as retryable so RetryDoer / user retry logic kicks in.
func Test_SendRichMessage_ServerError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageParams{
ChatID: ChatIDFromInt(123),
RichMessage: InputRichMessage{},
}
_, err := SendRichMessage(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 500, ae.Code)
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_SendRichMessageDraft_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/sendRichMessageDraft")
})).Return(genTestResp(200, `{"ok":true,"result":true}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.NoError(t, err)
}
func Test_SendRichMessageDraft_APIError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 429, ae.Code)
require.True(t, ae.IsRetryable())
}
func Test_SendRichMessageDraft_NetworkError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.Error(t, err)
var ne *client.NetworkError
require.ErrorAs(t, err, &ne)
}
func Test_SendRichMessageDraft_ParseError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.Error(t, err)
var pe *client.ParseError
require.ErrorAs(t, err, &pe)
}
func Test_SendRichMessageDraft_ContextCanceled(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(ctx, bot, params)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
}
// Test_SendRichMessageDraft_MissingRequiredFields exercises Telegram's server-side
// validation: when a required field is omitted, Telegram returns 400 with
// a description like "Bad Request: <field> is empty". The library must
// surface this as *APIError with the ErrBadRequest sentinel.
func Test_SendRichMessageDraft_MissingRequiredFields(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":400,"description":"Bad Request: chat_id is empty"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Send a Params with all required fields zeroed — simulates a caller
// that forgot to populate them. The bot library marshals as-is and
// surfaces Telegram's 400 reply.
_, err := SendRichMessageDraft(context.Background(), bot, &SendRichMessageDraftParams{})
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 400, ae.Code)
require.True(t, errors.Is(err, client.ErrBadRequest))
require.False(t, ae.IsRetryable())
}
// Test_SendRichMessageDraft_Forbidden exercises the 403 path (bot blocked by user,
// removed from chat, etc.). The library must surface the ErrForbidden
// sentinel.
func Test_SendRichMessageDraft_Forbidden(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 403, ae.Code)
require.True(t, errors.Is(err, client.ErrForbidden))
require.False(t, ae.IsRetryable())
}
// Test_SendRichMessageDraft_ServerError exercises the 5xx path. The library must
// classify these as retryable so RetryDoer / user retry logic kicks in.
func Test_SendRichMessageDraft_ServerError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
params := &SendRichMessageDraftParams{
ChatID: 42,
DraftID: 42,
RichMessage: InputRichMessage{},
}
_, err := SendRichMessageDraft(context.Background(), bot, params)
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 500, ae.Code)
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
func Test_AnswerInlineQuery_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
+1491 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -45,6 +45,7 @@ func (b *Bot) Logger() Logger { return b.logger }
// NewDefaultHTTPDoer); the default codec wraps encoding/json; the default
// logger discards records.
func New(token string, opts ...Option) *Bot {
fireTelemetryOnce()
b := &Bot{
token: token,
base: defaultBaseURL,
+33
View File
@@ -0,0 +1,33 @@
package client
import (
"sync"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// telemetryOnce guards the single anonymous "library used" ping that is sent
// on the first call to New. Long-running bots typically construct one Bot;
// short-lived programs or test suites may construct many, but the Once gate
// keeps the fire-and-forget call from amplifying into per-construction pings.
var telemetryOnce sync.Once
// fireTelemetryOnce dispatches a fire-and-forget anonymous adoption ping.
//
// The call is failproof by contract of oss-telemetry: it never blocks New,
// never panics, never returns errors, and silently no-ops if disabled or
// if the network is unavailable.
//
// Opt-out is honored via any of these environment variables (case-insensitive
// truthy values "1", "true", "yes", "on"):
//
// - DO_NOT_TRACK
// - OSS_TELEMETRY_DISABLED
// - GO_TELEGRAM_DISABLE_TELEMETRY
//
// See README §Telemetry for the full disclosure.
func fireTelemetryOnce() {
telemetryOnce.Do(func() {
telemetry.SendForModule("go-telegram", "github.com/lukaszraczylo/go-telegram", Version)
})
}
+54
View File
@@ -0,0 +1,54 @@
package client
import (
"os"
"sync"
"testing"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// TestMain disables outgoing telemetry for the duration of this package's
// test suite. The library's own tests construct many Bot instances; without
// this guard they would each contribute a real ping to the public endpoint.
// End-user test suites that construct Bot are not affected by this — only
// tests inside this package are.
func TestMain(m *testing.M) {
telemetry.Disable()
os.Exit(m.Run())
}
// TestFireTelemetryOnce_OnlyFiresOnce verifies the sync.Once gate. Even if
// New is called repeatedly, the underlying telemetry.Send is invoked at most
// once per process. We can't observe the network call directly (telemetry
// is disabled here via TestMain) so we assert on the once-Do count via a
// fresh local sync.Once paralleling the production one.
func TestFireTelemetryOnce_OnlyFiresOnce(t *testing.T) {
// Reset the package-level Once so this test starts from a clean state.
telemetryOnce = sync.Once{}
t.Cleanup(func() { telemetryOnce = sync.Once{} })
calls := 0
probe := func() { telemetryOnce.Do(func() { calls++ }) }
for i := 0; i < 50; i++ {
probe()
}
if calls != 1 {
t.Fatalf("expected exactly 1 Once execution, got %d", calls)
}
}
// TestNew_DoesNotPanicUnderRepeatedConstruction is a smoke test that
// telemetry wiring does not affect New's existing contract. New must never
// panic, regardless of telemetry state.
func TestNew_DoesNotPanicUnderRepeatedConstruction(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("New panicked: %v", r)
}
}()
for i := 0; i < 20; i++ {
_ = New("test-token-" + string(rune('A'+i)))
}
}
+11
View File
@@ -0,0 +1,11 @@
package client
// Version is a fallback version string used only when Go's build info is
// unavailable (replace directives, detached `go run`) or has been overridden
// via linker flags. The authoritative version forwarded to telemetry is
// resolved at runtime by [telemetry.SendForModule] from the build info of
// whatever binary linked this library, so this constant does NOT need to be
// bumped on every release. Exposed as a var (not const) for ldflag override:
//
// go build -ldflags="-X github.com/lukaszraczylo/go-telegram/client.Version=1.2.3"
var Version = "0.0.0-fallback"
+8 -8
View File
@@ -420,7 +420,7 @@ func funcs(plan *enumPlan) template.FuncMap {
return goField(plan, parent, f)
},
"goFieldP": func(methodName string, f spec.Field) string {
return goFieldX(plan, "", title(methodName)+"Params", f)
return goFieldX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
},
"docComment": docComment,
"isOptional": func(f spec.Field) bool { return !f.Required },
@@ -432,7 +432,7 @@ func funcs(plan *enumPlan) template.FuncMap {
return multipartFieldEntry(plan, parent, f)
},
"multipartFieldEntryP": func(methodName string, f spec.Field) string {
return multipartFieldEntryX(plan, "", title(methodName)+"Params", f)
return multipartFieldEntryX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
},
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
@@ -915,15 +915,15 @@ func buildUnionTypeSet(api *spec.API) map[string]bool {
// used in generated test param literals. plan supplies typed-enum names
// so a method-param sentinel for a ParseMode field becomes a typed
// constant rather than a magic string.
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(spec.Field) string {
return func(f spec.Field) string {
return sentinelForField(f, unionTypes, plan)
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(string, spec.Field) string {
return func(methodName string, f spec.Field) string {
return sentinelForField(methodName, f, unionTypes, plan)
}
}
func sentinelForField(f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
if name := plan.FieldEnum("", f.Name); name != "" && len(f.EnumValues) > 0 {
return constName(name, f.EnumValues[0])
func sentinelForField(methodName string, f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
if name := plan.FieldEnum(methodEnumParent(methodName), f.Name); name != "" && len(f.EnumValues) > 0 {
return plan.ConstFor(name, f.EnumValues[0])
}
tr := f.Type
switch tr.Kind {
+73 -10
View File
@@ -26,15 +26,21 @@ type enumPlan struct {
}
// enumKey identifies a single Field occurrence so the emitter can look
// up the enum name later. Parent is "" for method params (the method
// doesn't share a Go type with the field).
// up the enum name later. Parent is the type name for struct fields and
// methodEnumParent(name) for method params, so two methods sharing a
// param name never alias each other's enum (or non-enum) fields.
func enumKey(parent, fieldName string) string { return parent + "::" + fieldName }
// methodEnumParent is the enum-plan key parent for a method's params.
// The "method:" prefix keeps it disjoint from type names.
func methodEnumParent(methodName string) string { return "method:" + methodName }
// planEnums walks the IR, decides on enum names, deduplicates, and
// returns an enumPlan. All scraper-marked enum fields are covered.
func planEnums(api *spec.API) *enumPlan {
type ref struct {
parent string
parent string // naming parent ("" for method params)
keyParent string // byField key parent (methodEnumParent(...) for method params)
fieldName string
jsonName string
values []string
@@ -52,7 +58,7 @@ func planEnums(api *spec.API) *enumPlan {
}
var refs []ref
collect := func(parent string, fields []spec.Field) {
collect := func(parent, keyParent string, fields []spec.Field) {
for _, f := range fields {
if len(f.EnumValues) == 0 {
continue
@@ -62,6 +68,7 @@ func planEnums(api *spec.API) *enumPlan {
}
refs = append(refs, ref{
parent: parent,
keyParent: keyParent,
fieldName: f.Name,
jsonName: f.JSONName,
values: f.EnumValues,
@@ -70,13 +77,15 @@ func planEnums(api *spec.API) *enumPlan {
}
}
for _, t := range api.Types {
collect(t.Name, t.Fields)
collect(t.Name, t.Name, t.Fields)
}
for _, m := range api.Methods {
// Method params have no shared Go parent type, so we pass "" as
// the parent. The default-name heuristic still produces the
// right answer for ParseMode-style enums.
collect("", m.Params)
// Method params have no shared Go parent type, so the naming
// parent is "" (the default-name heuristic still produces the
// right answer for ParseMode-style enums), but the byField key
// is method-scoped so a same-named non-enum param on another
// method can never pick up this enum.
collect("", methodEnumParent(m.Name), m.Params)
}
// candidate name per ref (before collision resolution)
@@ -196,7 +205,7 @@ func planEnums(api *spec.API) *enumPlan {
}
for i, r := range refs {
name := groups[r.valueKey].name
plan.byField[enumKey(r.parent, r.fieldName)] = name
plan.byField[enumKey(r.keyParent, r.fieldName)] = name
_ = i
}
for vk, g := range groups {
@@ -389,6 +398,18 @@ func (p *enumPlan) FieldEnum(parent, fieldName string) string {
return p.byField[enumKey(parent, fieldName)]
}
// ConstFor returns the collision-resolved constant identifier for value
// within the named enum declaration. Falls back to the plain constName
// when the declaration is unknown (unit tests with partial plans).
func (p *enumPlan) ConstFor(enumName, value string) string {
if p != nil {
if d, ok := p.decls[enumName]; ok {
return d.ConstName(value)
}
}
return constName(enumName, value)
}
// defaultEnumName picks an initial Go enum name for a field. parse_mode
// fields collapse to the canonical "ParseMode"; otherwise the name is
// parent + PascalCase(jsonName).
@@ -406,6 +427,48 @@ func constName(enumName, value string) string {
return enumName + valuePascal(value)
}
// ConstName returns the constant identifier for value within this enum,
// resolving case-collisions between values that Pascal-case to the same
// identifier (e.g. RichBlockListItem.type values "a" and "A"). Colliding
// values get a Lower/Upper prefix on the value part based on the case of
// the value's first letter; any residual collision gets a numeric suffix.
func (e enumDecl) ConstName(value string) string {
counts := map[string]int{}
for _, v := range e.Values {
counts[constName(e.Name, v)]++
}
plain := constName(e.Name, value)
if counts[plain] <= 1 {
return plain
}
cased := e.Name + casePrefix(value) + valuePascal(value)
// Residual collision (same value text repeated, or Lower/Upper still
// ambiguous): append the value's 1-based position for determinism.
casedCounts := map[string]int{}
pos := 0
for i, v := range e.Values {
if counts[constName(e.Name, v)] > 1 {
casedCounts[e.Name+casePrefix(v)+valuePascal(v)]++
}
if v == value {
pos = i + 1
}
}
if casedCounts[cased] > 1 {
return cased + itoa(pos)
}
return cased
}
// casePrefix distinguishes case-colliding enum values: "Lower" when the
// value starts with a lowercase letter, "Upper" otherwise.
func casePrefix(v string) string {
if v != "" && v[0] >= 'a' && v[0] <= 'z' {
return "Lower"
}
return "Upper"
}
func valuePascal(v string) string {
// "image/jpeg" → "ImageOfJpeg"
parts := strings.Split(v, "/")
+1 -1
View File
@@ -8,6 +8,6 @@ package api
type {{$e.Name}} string
const (
{{range $v := $e.Values}} {{enumConstName $e.Name $v}} {{$e.Name}} = {{printf "%q" $v}}
{{range $v := $e.Values}} {{$e.ConstName $v}} {{$e.Name}} = {{printf "%q" $v}}
{{end}})
{{end}}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// Regression: Bot API 9.x RichBlockListItem.type enumerates "a"/"A"/"i"/"I"/"1",
// which Pascal-case to the same identifier. ConstName must disambiguate.
func TestEnumDecl_ConstName_CaseCollision(t *testing.T) {
d := enumDecl{Name: "RichBlockListItemType", Values: []string{"a", "A", "i", "I", "1"}}
got := map[string]bool{}
for _, v := range d.Values {
n := d.ConstName(v)
require.False(t, got[n], "duplicate const ident %q for value %q", n, v)
got[n] = true
}
require.Equal(t, "RichBlockListItemTypeLowerA", d.ConstName("a"))
require.Equal(t, "RichBlockListItemTypeUpperA", d.ConstName("A"))
// Non-colliding values keep the plain name.
require.Equal(t, "RichBlockListItemType1", d.ConstName("1"))
}
// Regression: answerChatJoinRequestQuery has an enum param named "result";
// answerGuestQuery has a NON-enum param also named "result". The enum plan
// must scope method params per method so the enum never leaks onto the
// other method's field.
func TestPlanEnums_MethodParamsScopedPerMethod(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "answerChatJoinRequestQuery",
Params: []spec.Field{{
Name: "Result", JSONName: "result", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"},
EnumValues: []string{"approve", "decline", "queue"},
}},
},
{
Name: "answerGuestQuery",
Params: []spec.Field{{
Name: "Result", JSONName: "result", Required: true,
Type: spec.TypeRef{Kind: spec.KindNamed, Name: "InlineQueryResult"},
}},
},
},
}
plan := planEnums(api)
require.Equal(t, "Result",
plan.FieldEnum(methodEnumParent("answerChatJoinRequestQuery"), "Result"))
require.Empty(t,
plan.FieldEnum(methodEnumParent("answerGuestQuery"), "Result"),
"non-enum param must not inherit another method's enum")
}
+1 -1
View File
@@ -574,7 +574,7 @@ func TestSentinelForField(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := sentinelForField(c.field, unionTypes, nil)
got := sentinelForField("testMethod", c.field, unionTypes, nil)
require.Contains(t, got, c.contains, "sentinelForField for %q", c.name)
})
}
+7 -7
View File
@@ -48,7 +48,7 @@ func Test_{{$mName}}_Success(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -67,7 +67,7 @@ func Test_{{$mName}}_APIError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -89,7 +89,7 @@ func Test_{{$mName}}_NetworkError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -109,7 +109,7 @@ func Test_{{$mName}}_ParseError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -132,7 +132,7 @@ func Test_{{$mName}}_ContextCanceled(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(ctx, bot, params)
@@ -177,7 +177,7 @@ func Test_{{$mName}}_Forbidden(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -203,7 +203,7 @@ func Test_{{$mName}}_ServerError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
+2497 -734
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -87,6 +87,16 @@ var (
)
```
<a name="Version"></a>Version is a fallback version string used only when Go's build info is unavailable \(replace directives, detached \`go run\`\) or has been overridden via linker flags. The authoritative version forwarded to telemetry is resolved at runtime by \[telemetry.SendForModule\] from the build info of whatever binary linked this library, so this constant does NOT need to be bumped on every release. Exposed as a var \(not const\) for ldflag override:
```
go build -ldflags="-X github.com/lukaszraczylo/go-telegram/client.Version=1.2.3"
```
```go
var Version = "0.0.0-fallback"
```
<a name="Call"></a>
## func [Call](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/call.go#L68>)
+3 -2
View File
@@ -4,8 +4,10 @@ go 1.25.0
require (
github.com/goccy/go-json v0.10.6
github.com/lukaszraczylo/oss-telemetry v0.2.1
github.com/stretchr/testify v1.9.0
golang.org/x/net v0.54.0
github.com/valyala/fasthttp v1.71.0
golang.org/x/net v0.55.0
)
require (
@@ -15,6 +17,5 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.71.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6 -2
View File
@@ -6,6 +6,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -16,8 +18,10 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+2373 -8
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
snapshot_2026-05-20.html
snapshot_2026-06-11.html
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long