From 7ac0842ec857a114bd18b41863e9eddf4225ea37 Mon Sep 17 00:00:00 2001 From: OvyFlash Date: Mon, 24 Aug 2026 22:59:07 +0300 Subject: [PATCH 1/5] Add Telegram Bot API 10.3 support Replace flat ephemeral send fields with EphemeralMessageParameters and add rich-message buttons/documents, draft stop controls, and related update types. Co-authored-by: Cursor --- .cursor/rules/telegram-bot-api-updates.mdc | 69 ++++ ...telegram-bot-api-verification-pipeline.mdc | 61 ++++ .cursor/rules/telegram-type-patterns.mdc | 87 +++++ bot_api_10_2_test.go | 77 +++-- bot_api_10_3_test.go | 230 ++++++++++++++ configs.go | 299 ++++++++++-------- docs/examples/bot-api-10.md | 10 +- examples/bot_api_10.go | 4 +- helper_methods.go | 8 +- parity_interfaces_test.go | 2 + types.go | 135 +++++++- upload.go | 10 + 12 files changed, 821 insertions(+), 171 deletions(-) create mode 100644 .cursor/rules/telegram-bot-api-updates.mdc create mode 100644 .cursor/rules/telegram-bot-api-verification-pipeline.mdc create mode 100644 .cursor/rules/telegram-type-patterns.mdc create mode 100644 bot_api_10_3_test.go diff --git a/.cursor/rules/telegram-bot-api-updates.mdc b/.cursor/rules/telegram-bot-api-updates.mdc new file mode 100644 index 00000000..ea96a15a --- /dev/null +++ b/.cursor/rules/telegram-bot-api-updates.mdc @@ -0,0 +1,69 @@ +--- +description: How Telegram Bot API version updates are applied in this library +alwaysApply: true +--- + +# Telegram Bot API Update Workflow + +## Source of truth + +1. Official changelog: https://core.telegram.org/bots/api-changelog +2. Method/type docs: https://core.telegram.org/bots/api +3. Parity schema (CI): `https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/main/api.json` → local gitignored `api.json` + +Implement **one Bot API version per PR/branch**. Branch: `bot-api-X.Y`. Do not skip versions. + +## File map (touch only what the changelog needs) + +| Area | File | What to add | +|------|------|-------------| +| Types / Update fields | `types.go` | New types; fields on `Update`, `Message`, etc. with `json:"snake_case,omitempty"` | +| Request configs | `configs.go` | `*Config` + `method()` + `params()` (+ `files()` if uploads) | +| Shared bases | `helper_structs.go` | Embeddable bases (`BaseChat`, `BaseEdit`, `BaseEphemeralMessage`, …) | +| Helpers | `helper_methods.go` | `New*` constructors for required params | +| Uploads | `upload.go` | Recursive attach/`file_id` logic for media-heavy configs | +| Convenience API | `bot.go` | Typed wrappers when return ≠ `Message` via `Send` (or bool/string/etc.) | +| Compile checks | `parity_interfaces_test.go` | `_ Chattable = …{}` / `_ Fileable = …{}` | +| Tests | `configs_test.go`, `types_test.go`, optional `bot_api_X_Y_test.go` | Params/JSON/unmarshal coverage | +| Docs/examples | `docs/examples/`, `examples/` | Only for major user-facing features | + +## Naming & patterns + +- Config: PascalCase of API method + `Config` (`sendRichMessage` → `SendRichMessageConfig`). +- Helper: `New` + Config name without suffix (`NewSendRichMessage`). +- Bot method: PascalCase API method (`EditEphemeralMessageText`). +- Prefer embedding bases over duplicating chat/ephemeral/edit fields. +- `params()`: start from embedded base, then `Params` helpers (`AddNonEmpty`, `AddNonZero`, `AddNonZero64`, `AddBool`, `AddFirstValid`, `AddInterface`, `Merge`). +- Chat targeting: `ChatID int64` + `ChannelUsername` (via `ChatConfig` / `BaseChat`), never invent alternate ID shapes. +- New `Update` fields: add JSON field + extend `SentFrom` / `FromChat` switch arms when applicable. +- **Docs field order:** keep struct fields / new types / method params in the same order as on core.telegram.org/bots/api (legacy code often doesn’t; don’t churn old order unless already editing that struct—see `telegram-type-patterns` rule). +- No comments unless behavior is non-obvious; match surrounding style (tabs in Go). + +## Validation order + +1. Mirror changelog bullets completely (types → fields → methods → params). +2. `go vet ./...` (do not use full `go build` for intermediate checks). +3. Unit tests for new params/JSON. +4. Optional: fetch `api.json`, run `go test -tags=api_parity ./...`; only extend allowlists in `api_parity_test.go` for documented legacy/promoted extras. + +## Commit / PR style (from history) + +- Message: `Add Telegram Bot API X.Y support` or `Bot API X.Y: `. +- Follow-up fixes for the same version are separate small PRs (e.g. missing ephemeral params). +- Keep parity/serialization fixes separate from the version bump when possible. + +## Version baseline (do not confuse tag ≠ master) + +| Signal | Value | +|--------|--------| +| Latest **git tag** | `v9.4.0` (tags lag; README discourages tagging) | +| Latest **code on `master`** | Bot API **10.3** present | +| Latest **explicit version commit** | Bot API 10.3 implementation (this branch) | +| Next official | — track changelog after 10.3 | + +Notes: + +- **8.3 is not current** on `master` (that landing was `61bd317` / PR #65, 2025-05). +- No commits titled 9.0–9.3 or 10.1; those features were bundled (`2f2184e` ≈ 9.0–9.4 catch-up; `c20b6c3` ≈ 10.1). +- Always judge “implemented” by **types/methods on `master`**, not by the newest tag or a single commit title. +- Coverage gate: follow `telegram-bot-api-verification-pipeline` (curated audit: 9.0–10.3 OK after 10.3 landing). diff --git a/.cursor/rules/telegram-bot-api-verification-pipeline.mdc b/.cursor/rules/telegram-bot-api-verification-pipeline.mdc new file mode 100644 index 00000000..87ca6343 --- /dev/null +++ b/.cursor/rules/telegram-bot-api-verification-pipeline.mdc @@ -0,0 +1,61 @@ +--- +description: Pipeline to verify Bot API version coverage before/during implementation +alwaysApply: true +--- + +# Bot API Verification Pipeline + +Run this before choosing a version bump and again before declaring a version done. + +## A. Establish baseline + +1. Read https://core.telegram.org/bots/api-changelog from last implemented version → target. +2. Treat **`master` types/methods** as truth (not git tags). Current implemented ceiling: **10.3**. +3. Skip Mini App **client-only** APIs (`WebApp.hideKeyboard`, DeviceStorage, etc.) — not HTTP Bot API. + +## B. Changelog → checklist + +For each version bullet, classify: + +| Kind | How to verify in repo | +|------|------------------------| +| New class | `type Name` or alias `Name =` in `types.go` | +| New method | `method()` returns `"camelCaseName"` in a `*Config` | +| New field | `json:"snake_case` on the owning struct (prefer docs field order) | +| New parameter | serialized in that config’s `params()` / embedded base | +| Replacement | old name gone or migrated; new name present | +| Update payload | field on `Update`/`Message` + `SentFrom`/`FromChat` if needed | + +Ignore regex false positives (`allowing`, `and`, `from` from changelog prose). + +## C. Commands (local) + +```bash +go vet ./... +go test -count=1 ./... +curl -fsSL -o api.json \ + https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/main/api.json +go test -tags=api_parity -run '^TestAPIParity' -count=1 . +``` + +`api.json` is gitignored. Spec may lag a fresh Telegram release by hours/days — changelog wins if they disagree. + +## D. Pattern gates (must pass for new code) + +1. Embed bases (`BaseChat`, `BaseEphemeralMessage`, …) — see `telegram-type-patterns`. +2. Inbound unions flattened (`TransactionPartner`-style), not interface JSON. +3. Field/param order follows core.telegram.org/bots/api for **new** fields. +4. Helpers `New*`, `parity_interfaces_test.go` compile checks, tests for params/JSON. +5. Breaking renames (e.g. 10.3 `ephemeral_message_parameters`): update configs + helpers + tests + examples together. + +## E. Decision rule + +- If any **prior** version on the checklist has gaps → implement the **oldest** gapped version first. +- Else implement the **next** changelog version only (one version per branch/PR). +- After merge, update the Version baseline table in `telegram-bot-api-updates.mdc`. + +## F. Post-8.3 audit snapshot + +| Version | Curated checklist | +|---------|-------------------| +| 9.0–10.3 | Present on `master` (as of Bot API 10.3 landing) | diff --git a/.cursor/rules/telegram-type-patterns.mdc b/.cursor/rules/telegram-type-patterns.mdc new file mode 100644 index 00000000..786098c7 --- /dev/null +++ b/.cursor/rules/telegram-type-patterns.mdc @@ -0,0 +1,87 @@ +--- +description: Embedding and Telegram polymorphic type patterns (TransactionPartner-style) +globs: "{types.go,configs.go,helper_structs.go,helper_methods.go,upload.go}" +alwaysApply: false +--- + +# Type & Embedding Patterns + +Reuse these shapes for new Bot API types. Do not invent interface-based JSON unions for inbound Telegram objects. + +## 0) Field & type order (docs order) + +Match https://core.telegram.org/bots/api field order as closely as practical: + +- Struct fields: same order as listed on the type/method page (required first as Telegram lists them, then optionals in doc order). +- New types in a version bump: introduce them in changelog / docs appearance order when adding a block of related types. +- Method params in `*Config` / `params()`: follow the Bot API parameter table order after embedded base fields. +- Do **not** mass-reorder old structs just to fix legacy drift; previous authors often ignored this. Apply docs order for **new** fields/types and when you already touch a struct for an API change (place the new field where the docs put it). + +## 1) Config embedding (requests) + +Shared request fields live in `helper_structs.go` and are **embedded** into configs: + +| Base | Use for | +|------|---------| +| `ChatConfig` | chat_id / channel username | +| `BaseChat` | outgoing chat messages (reply, protect, effects, …) | +| `BaseChatMessage` / `BaseChatMessages` | edit/delete by message id(s) | +| `BaseEdit` | inline-or-chat edits | +| `BaseFile` | single-file sends | +| `BaseEphemeralMessage` | ephemeral edit/delete | +| `BaseInputMedia` | `InputMedia*` variants | + +```go +type EditEphemeralMessageTextConfig struct { + BaseEphemeralMessage + Text string + // ... +} + +func (c EditEphemeralMessageTextConfig) params() (Params, error) { + params, err := c.BaseEphemeralMessage.params() + // add only this config's fields + return params, err +} +``` + +Never copy-paste the same chat/ephemeral fields into each config. + +## 2) Flattened polymorphic types (responses) + +Telegram docs list exclusive variants (`TransactionPartnerUser`, `PaidMediaPhoto`, …). In this library, **inbound** unions are one struct with a discriminator + optional variant fields: + +```go +type TransactionPartner struct { + Type string `json:"type"` + User User `json:"user,omitempty"` // "user" + Chat Chat `json:"chat,omitempty"` // "chat" + WithdrawalState *RevenueWithdrawalState `json:"withdrawal_state,omitempty"` // "fragment" + RequestCount int `json:"request_count,omitempty"` // "telegram_api" + // ... +} +``` + +Same pattern: `ChatMember`, `MessageOrigin`, `ReactionType`, `PaidMedia`, `ChatBoostSource`, `RevenueWithdrawalState`, `BackgroundType`, `StoryAreaType`. + +Rules: + +- Discriminator: `Type` or `Source` / `Status` as Telegram names it. +- Variant-only fields: `omitempty` + comment which variant(s) use them. +- Optional helpers: `IsUser()`, `IsEmoji()`, … +- Type-name constants when needed for parity (`MessageOriginUser = "user"`). +- At file end, add **aliases** so Telegram type names compile: `TransactionPartnerOther = TransactionPartner`, `PaidMediaPhoto = PaidMedia`. +- Dedicated variant structs (`TransactionPartnerUser`) are for **docs/constructors/outbound clarity**, not separate JSON unmarshal targets for that union. + +## 3) Outbound interfaces (when needed) + +Use small interfaces only for **sending** polymorphic payloads the bot builds: + +- `InputMedia` (+ `BaseInputMedia` embed) +- `InputProfilePhoto`, `InputStoryContent`, `InlineQueryResults` + +Prefer concrete structs with `Type` set in `New*` helpers. + +## 4) Update / Message field wiring + +New update payload → field on `Update` / `Message` with `json:"snake,omitempty"`, then extend `(*Update).SentFrom` / `FromChat` if a user/chat is primary. diff --git a/bot_api_10_2_test.go b/bot_api_10_2_test.go index 8611c451..ad471323 100644 --- a/bot_api_10_2_test.go +++ b/bot_api_10_2_test.go @@ -29,14 +29,17 @@ func TestBotAPI102InputRichBlockJSONContract(t *testing.T) { {name: "anchor", block: InputRichBlockAnchor{Type: "anchor", Name: "intro"}, typeName: "anchor", fieldMatch: `"name":"intro"`}, {name: "list", block: InputRichBlockList{Type: "list", Items: []InputRichBlockListItem{{Blocks: []InputRichBlock{InputRichBlockParagraph{Type: "paragraph", Text: "item"}}, HasCheckbox: true}}}, typeName: "list", fieldMatch: `"has_checkbox":true`}, {name: "block quotation", block: InputRichBlockBlockQuotation{Type: "blockquote", Blocks: []InputRichBlock{InputRichBlockParagraph{Type: "paragraph", Text: "quote"}}, Credit: "author"}, typeName: "blockquote", fieldMatch: `"credit":"author"`}, + {name: "expandable quotation", block: InputRichBlockExpandableBlockQuotation{Type: "expandable_blockquote", Text: "quote", Credit: "author"}, typeName: "expandable_blockquote", fieldMatch: `"credit":"author"`}, {name: "pull quotation", block: InputRichBlockPullQuotation{Type: "pullquote", Text: "quote", Credit: "author"}, typeName: "pullquote", fieldMatch: `"credit":"author"`}, {name: "collage", block: InputRichBlockCollage{Type: "collage", Blocks: []InputRichBlock{InputRichBlockPhoto{Type: "photo", Photo: photo}}}, typeName: "collage", fieldMatch: `"blocks":[`}, {name: "slideshow", block: InputRichBlockSlideshow{Type: "slideshow", Blocks: []InputRichBlock{InputRichBlockPhoto{Type: "photo", Photo: photo}}}, typeName: "slideshow", fieldMatch: `"blocks":[`}, - {name: "table", block: InputRichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Text: "cell", Align: "left", Valign: "middle"}}}, IsBordered: true}, typeName: "table", fieldMatch: `"is_bordered":true`}, + {name: "table", block: InputRichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Text: "cell", Align: "left", Valign: "middle"}}}, IsBordered: true, IsCompact: true}, typeName: "table", fieldMatch: `"is_compact":true`}, {name: "details", block: InputRichBlockDetails{Type: "details", Summary: "summary", Blocks: []InputRichBlock{InputRichBlockParagraph{Type: "paragraph", Text: "body"}}, IsOpen: true}, typeName: "details", fieldMatch: `"is_open":true`}, {name: "map", block: InputRichBlockMap{Type: "map", Location: Location{Latitude: 10.5, Longitude: 20.25}, Zoom: 12, Width: 640, Height: 480}, typeName: "map", fieldMatch: `"zoom":12`}, + {name: "buttons", block: InputRichBlockButtons{Type: "buttons", Buttons: []RichMessageButton{{Text: "Go", CallbackData: "go"}}, Align: "center"}, typeName: "buttons", fieldMatch: `"align":"center"`}, {name: "animation", block: InputRichBlockAnimation{Type: "animation", Animation: animation}, typeName: "animation", fieldMatch: `"animation":{"type":"animation","media":"animation"}`}, {name: "audio", block: InputRichBlockAudio{Type: "audio", Audio: audio}, typeName: "audio", fieldMatch: `"audio":{"type":"audio","media":"audio"}`}, + {name: "document", block: InputRichBlockDocument{Type: "document", Document: NewInputMediaDocument(FileID("document"))}, typeName: "document", fieldMatch: `"document":{"type":"document","media":"document"}`}, {name: "photo", block: InputRichBlockPhoto{Type: "photo", Photo: photo}, typeName: "photo", fieldMatch: `"photo":{"type":"photo","media":"photo"}`}, {name: "video", block: InputRichBlockVideo{Type: "video", Video: video}, typeName: "video", fieldMatch: `"video":{"type":"video","media":"video"}`}, {name: "voice note", block: InputRichBlockVoiceNote{Type: "voice_note", VoiceNote: voiceNote}, typeName: "voice_note", fieldMatch: `"voice_note":{"type":"voice_note","media":"voice"}`}, @@ -158,11 +161,18 @@ func TestBotAPI102NewEphemeralMessageDirectAdminParams(t *testing.T) { if err != nil { t.Fatalf("params: %v", err) } - if params["chat_id"] != "-1001" || params["receiver_user_id"] != "42" || params["text"] != "private" { + if params["chat_id"] != "-1001" || params["text"] != "private" { t.Fatalf("direct admin ephemeral params mismatch: %#v", params) } - if _, ok := params["callback_query_id"]; ok { - t.Fatalf("direct admin request unexpectedly contains callback query: %#v", params) + raw, ok := params["ephemeral_message_parameters"] + if !ok { + t.Fatalf("missing ephemeral_message_parameters: %#v", params) + } + if !strings.Contains(raw, `"receiver_user_id":42`) { + t.Fatalf("unexpected ephemeral_message_parameters: %s", raw) + } + if strings.Contains(raw, "callback_query_id") { + t.Fatalf("direct admin request unexpectedly contains callback query: %s", raw) } if _, ok := params["reply_parameters"]; ok { t.Fatalf("direct admin request unexpectedly contains reply parameters: %#v", params) @@ -170,32 +180,33 @@ func TestBotAPI102NewEphemeralMessageDirectAdminParams(t *testing.T) { } func TestBotAPI102EphemeralSendParams(t *testing.T) { + ephemeral := &EphemeralMessageParameters{ReceiverUserID: 42, CallbackQueryID: "callback"} message := NewMessage(1, "text") - message.ReceiverUserID, message.CallbackQueryID = 42, "callback" + message.EphemeralMessageParameters = ephemeral animation := NewAnimation(1, FileID("animation")) - animation.ReceiverUserID, animation.CallbackQueryID = 42, "callback" + animation.EphemeralMessageParameters = ephemeral audio := NewAudio(1, FileID("audio")) - audio.ReceiverUserID, audio.CallbackQueryID = 42, "callback" + audio.EphemeralMessageParameters = ephemeral document := NewDocument(1, FileID("document")) - document.ReceiverUserID, document.CallbackQueryID = 42, "callback" + document.EphemeralMessageParameters = ephemeral photo := NewPhoto(1, FileID("photo")) - photo.ReceiverUserID, photo.CallbackQueryID = 42, "callback" + photo.EphemeralMessageParameters = ephemeral livePhoto := NewLivePhoto(1, FileID("live-photo"), FileID("photo")) - livePhoto.ReceiverUserID, livePhoto.CallbackQueryID = 42, "callback" + livePhoto.EphemeralMessageParameters = ephemeral sticker := NewSticker(1, FileID("sticker")) - sticker.ReceiverUserID, sticker.CallbackQueryID = 42, "callback" + sticker.EphemeralMessageParameters = ephemeral video := NewVideo(1, FileID("video")) - video.ReceiverUserID, video.CallbackQueryID = 42, "callback" + video.EphemeralMessageParameters = ephemeral videoNote := NewVideoNote(1, 10, FileID("video-note")) - videoNote.ReceiverUserID, videoNote.CallbackQueryID = 42, "callback" + videoNote.EphemeralMessageParameters = ephemeral voice := NewVoice(1, FileID("voice")) - voice.ReceiverUserID, voice.CallbackQueryID = 42, "callback" + voice.EphemeralMessageParameters = ephemeral contact := NewContact(1, "+12025550123", "Ada") - contact.ReceiverUserID, contact.CallbackQueryID = 42, "callback" + contact.EphemeralMessageParameters = ephemeral location := NewLocation(1, 10.5, 20.25) - location.ReceiverUserID, location.CallbackQueryID = 42, "callback" + location.EphemeralMessageParameters = ephemeral venue := NewVenue(1, "Office", "Main Street", 10.5, 20.25) - venue.ReceiverUserID, venue.CallbackQueryID = 42, "callback" + venue.EphemeralMessageParameters = ephemeral configs := []struct { method string @@ -224,8 +235,12 @@ func TestBotAPI102EphemeralSendParams(t *testing.T) { if err != nil { t.Fatalf("params: %v", err) } - if params["receiver_user_id"] != "42" || params["callback_query_id"] != "callback" { - t.Fatalf("ephemeral send params mismatch: %#v", params) + raw, ok := params["ephemeral_message_parameters"] + if !ok { + t.Fatalf("missing ephemeral_message_parameters: %#v", params) + } + if !strings.Contains(raw, `"receiver_user_id":42`) || !strings.Contains(raw, `"callback_query_id":"callback"`) { + t.Fatalf("ephemeral send params mismatch: %s", raw) } }) } @@ -235,11 +250,8 @@ func TestBotAPI102EphemeralSendParams(t *testing.T) { if err != nil { t.Fatalf("chat action params: %v", err) } - if _, ok := params["receiver_user_id"]; ok { - t.Fatalf("receiver_user_id leaked into unsupported method: %#v", params) - } - if _, ok := params["callback_query_id"]; ok { - t.Fatalf("callback_query_id leaked into unsupported method: %#v", params) + if _, ok := params["ephemeral_message_parameters"]; ok { + t.Fatalf("ephemeral_message_parameters leaked into unsupported method: %#v", params) } } @@ -247,7 +259,7 @@ func TestBotAPI102EphemeralLifecycleParams(t *testing.T) { markup := NewInlineKeyboardMarkup(NewInlineKeyboardRow(NewInlineKeyboardButtonData("Done", "done"))) text := NewEditEphemeralMessageText(1, 2, 3, "updated") text.ParseMode = ModeMarkdownV2 - mediaValue := NewInputMediaPhoto(FileBytes{Name: "photo.jpg", Bytes: []byte("photo")}) + mediaValue := NewInputMediaPhoto(FileID("photo")) media := NewEditEphemeralMessageMedia(1, 2, 3, &mediaValue) caption := NewEditEphemeralMessageCaption(1, 2, 3, "") replyMarkup := NewEditEphemeralMessageReplyMarkup(1, 2, 3, markup) @@ -260,7 +272,7 @@ func TestBotAPI102EphemeralLifecycleParams(t *testing.T) { value string }{ {config: text, method: "editEphemeralMessageText", key: "text", value: "updated"}, - {config: media, method: "editEphemeralMessageMedia", key: "media", value: `{"type":"photo","media":{"Name":"photo.jpg","Bytes":"cGhvdG8="}}`}, + {config: media, method: "editEphemeralMessageMedia", key: "media", value: `{"type":"photo","media":"photo"}`}, {config: caption, method: "editEphemeralMessageCaption", key: "caption", value: ""}, {config: replyMarkup, method: "editEphemeralMessageReplyMarkup", key: "reply_markup", value: `{"inline_keyboard":[[{"text":"Done","callback_data":"done"}]]}`}, {config: deleteMessage, method: "deleteEphemeralMessage"}, @@ -281,14 +293,21 @@ func TestBotAPI102EphemeralLifecycleParams(t *testing.T) { if test.key != "" { value, ok := params[test.key] if !ok || value != test.value { - t.Fatalf("%s mismatch: %#v", test.key, params) + t.Fatalf("%s mismatch: got %q want %q in %#v", test.key, value, test.value, params) } } }) } - if _, ok := any(media).(Fileable); ok { - t.Fatal("ephemeral media edit must not support direct file uploads") + uploadMedia := NewInputMediaPhoto(FileBytes{Name: "photo.jpg", Bytes: []byte("photo")}) + uploadConfig := NewEditEphemeralMessageMedia(1, 2, 3, &uploadMedia) + fileable, ok := any(uploadConfig).(Fileable) + if !ok { + t.Fatal("ephemeral media edit must support direct file uploads") + } + files := fileable.files() + if len(files) != 1 || files[0].Name != "file-0" { + t.Fatalf("unexpected ephemeral media upload files: %#v", files) } } diff --git a/bot_api_10_3_test.go b/bot_api_10_3_test.go new file mode 100644 index 00000000..236f2216 --- /dev/null +++ b/bot_api_10_3_test.go @@ -0,0 +1,230 @@ +package tgbotapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBotAPI103EphemeralMessageParameters(t *testing.T) { + config := NewSendRichMessage(1, NewInputRichMessageHTML("

hi

")) + config.EphemeralMessageParameters = &EphemeralMessageParameters{ + ReceiverUserID: 42, + CallbackQueryID: "cbq", + ReplaceCallbackQueryMessage: true, + } + + params, err := config.params() + if err != nil { + t.Fatalf("params: %v", err) + } + raw := params["ephemeral_message_parameters"] + if !strings.Contains(raw, `"receiver_user_id":42`) || + !strings.Contains(raw, `"callback_query_id":"cbq"`) || + !strings.Contains(raw, `"replace_callback_query_message":true`) { + t.Fatalf("unexpected ephemeral_message_parameters: %s", raw) + } +} + +func TestBotAPI103DraftStopParams(t *testing.T) { + draft := SendMessageDraftConfig{ + ChatConfig: ChatConfig{ChatID: 1}, + DraftID: 7, + Text: "partial", + CanStop: true, + KeepOnStop: true, + } + params, err := draft.params() + if err != nil { + t.Fatalf("params: %v", err) + } + if params["can_stop"] != "true" || params["keep_on_stop"] != "true" { + t.Fatalf("draft stop params mismatch: %#v", params) + } + + richDraft := NewSendRichMessageDraft(1, 7, NewInputRichMessageMarkdown("**hi**")) + richDraft.CanStop = true + richDraft.KeepOnStop = true + params, err = richDraft.params() + if err != nil { + t.Fatalf("rich draft params: %v", err) + } + if params["can_stop"] != "true" || params["keep_on_stop"] != "true" { + t.Fatalf("rich draft stop params mismatch: %#v", params) + } +} + +func TestBotAPI103EditEphemeralExtensions(t *testing.T) { + rich := NewInputRichMessageHTML("

hi

") + text := NewEditEphemeralMessageText(1, 2, 3, "") + text.RichMessage = &rich + params, err := text.params() + if err != nil { + t.Fatalf("params: %v", err) + } + if _, ok := params["text"]; ok { + t.Fatalf("empty text should be omitted when rich_message is set: %#v", params) + } + if !strings.Contains(params["rich_message"], `"html"`) || !strings.Contains(params["rich_message"], "hi") { + t.Fatalf("unexpected rich_message: %s", params["rich_message"]) + } + + caption := NewEditEphemeralMessageCaption(1, 2, 3, "cap") + caption.ShowCaptionAboveMedia = true + params, err = caption.params() + if err != nil { + t.Fatalf("caption params: %v", err) + } + if params["show_caption_above_media"] != "true" { + t.Fatalf("show_caption_above_media missing: %#v", params) + } +} + +func TestBotAPI103PromoteWelcomeMessages(t *testing.T) { + config := PromoteChatMemberConfig{ + ChatMemberConfig: ChatMemberConfig{ + ChatConfig: ChatConfig{ChatID: 1}, + UserID: 2, + }, + CanSendWelcomeMessages: true, + } + params, err := config.params() + if err != nil { + t.Fatalf("params: %v", err) + } + if params["can_send_welcome_messages"] != "true" { + t.Fatalf("can_send_welcome_messages missing: %#v", params) + } +} + +func TestBotAPI103NewTypesJSON(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + { + name: "rich message button", + value: RichMessageButton{ + Text: "Go", + Style: "primary", + CallbackData: "go", + Disabled: &DisabledButton{}, + }, + want: `"callback_data":"go"`, + }, + { + name: "rich text button", + value: RichTextButton{Type: "button", Button: RichMessageButton{Text: "Go", URL: "https://t.me"}}, + want: `"type":"button"`, + }, + { + name: "buttons block", + value: RichBlockButtons{Type: "buttons", Buttons: []RichMessageButton{{Text: "A", CallbackData: "a"}}, Align: "center"}, + want: `"align":"center"`, + }, + { + name: "expandable quotation", + value: RichBlockExpandableBlockQuotation{Type: "expandable_blockquote", Text: "quote"}, + want: `"type":"expandable_blockquote"`, + }, + { + name: "document block", + value: RichBlockDocument{Type: "document", Document: Document{FileID: "doc"}}, + want: `"type":"document"`, + }, + { + name: "compact table", + value: RichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Align: "left", Valign: "top"}}}, IsCompact: true}, + want: `"is_compact":true`, + }, + { + name: "input document block", + value: InputRichBlockDocument{ + Type: "document", + Document: NewInputMediaDocument(FileID("doc")), + }, + want: `"type":"document"`, + }, + { + name: "unique gift info", + value: UniqueGiftInfo{ + Gift: UniqueGift{BaseName: "Gift"}, + Origin: "transfer", + Text: "hello", + IsPrivate: true, + }, + want: `"is_private":true`, + }, + { + name: "message generation stopped", + value: MessageGenerationStopped{ + Chat: Chat{ID: 1}, + DraftID: 9, + }, + want: `"draft_id":9`, + }, + { + name: "community chat joined", + value: CommunityChatJoined{Community: Community{ID: 5, Name: "Ops"}}, + want: `"name":"Ops"`, + }, + { + name: "keyboard force reply", + value: InlineKeyboardMarkup{ + InlineKeyboard: [][]InlineKeyboardButton{{{Text: "A", CallbackData: stringPtr("a")}}}, + ForceReply: true, + }, + want: `"force_reply":true`, + }, + { + name: "disabled inline button", + value: InlineKeyboardButton{ + Text: "Nope", + Disabled: &DisabledButton{}, + }, + want: `"disabled":{}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := json.Marshal(test.value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), test.want) { + t.Fatalf("got %s, want substring %q", raw, test.want) + } + }) + } +} + +func TestBotAPI103UpdateStoppedMessageGeneration(t *testing.T) { + var update Update + if err := json.Unmarshal([]byte(`{ + "update_id":1, + "stopped_message_generation":{"chat":{"id":10,"type":"private"},"draft_id":3} + }`), &update); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if update.StoppedMessageGeneration == nil || update.StoppedMessageGeneration.DraftID != 3 { + t.Fatalf("unexpected update: %#v", update.StoppedMessageGeneration) + } + if chat := update.FromChat(); chat == nil || chat.ID != 10 { + t.Fatalf("FromChat mismatch: %#v", chat) + } +} + +func TestBotAPI103RichDocumentUpload(t *testing.T) { + document := NewInputMediaDocument(FileBytes{Name: "notes.pdf", Bytes: []byte("%PDF")}) + config := NewSendRichMessage(1, NewInputRichMessageBlocks(InputRichBlockDocument{ + Type: "document", + Document: document, + })) + assertRichMessageUpload(t, config, []string{"rich-message-block-0"}) +} + +func stringPtr(v string) *string { + return &v +} diff --git a/configs.go b/configs.go index 4c56f49b..ca9abbe3 100644 --- a/configs.go +++ b/configs.go @@ -136,6 +136,9 @@ const ( // UpdateTypeSubscription is emitted when a user payment subscription changes. UpdateTypeSubscription = "subscription" + + // UpdateTypeStoppedMessageGeneration is emitted when a user stops message generation. + UpdateTypeStoppedMessageGeneration = "stopped_message_generation" ) // Library errors @@ -309,12 +312,11 @@ func (CloseConfig) params() (Params, error) { // MessageConfig contains information about a SendMessage request. type MessageConfig struct { BaseChat - Text string - ParseMode string - Entities []MessageEntity - LinkPreviewOptions LinkPreviewOptions - ReceiverUserID int64 - CallbackQueryID string + Text string + ParseMode string + Entities []MessageEntity + LinkPreviewOptions LinkPreviewOptions + EphemeralMessageParameters *EphemeralMessageParameters } func (config MessageConfig) params() (Params, error) { @@ -325,8 +327,9 @@ func (config MessageConfig) params() (Params, error) { params["text"] = config.Text params.AddNonEmpty("parse_mode", config.ParseMode) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("entities", config.Entities) if err != nil { return params, err @@ -370,6 +373,8 @@ type SendMessageDraftConfig struct { ThinkingPlaceholder bool ParseMode string Entities []MessageEntity + CanStop bool + KeepOnStop bool } func (config SendMessageDraftConfig) method() string { @@ -391,6 +396,11 @@ func (config SendMessageDraftConfig) params() (Params, error) { } params.AddNonEmpty("parse_mode", config.ParseMode) err = params.AddInterface("entities", config.Entities) + if err != nil { + return params, err + } + params.AddBool("can_stop", config.CanStop) + params.AddBool("keep_on_stop", config.KeepOnStop) return params, err } @@ -398,7 +408,8 @@ func (config SendMessageDraftConfig) params() (Params, error) { // SendRichMessageConfig allows you to send a rich message. type SendRichMessageConfig struct { BaseChat - RichMessage InputRichMessage + RichMessage InputRichMessage + EphemeralMessageParameters *EphemeralMessageParameters } func (config SendRichMessageConfig) method() string { @@ -413,6 +424,10 @@ func (config SendRichMessageConfig) params() (Params, error) { preparedRichMessage := prepareInputRichMessageForParams(config.RichMessage) err = params.AddInterface("rich_message", preparedRichMessage) + if err != nil { + return params, err + } + err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters) return params, err } @@ -427,6 +442,8 @@ type SendRichMessageDraftConfig struct { MessageThreadID int DraftID int RichMessage InputRichMessage + CanStop bool + KeepOnStop bool } func (config SendRichMessageDraftConfig) method() string { @@ -442,6 +459,11 @@ func (config SendRichMessageDraftConfig) params() (Params, error) { params.AddNonZero("message_thread_id", config.MessageThreadID) params.AddNonZero("draft_id", config.DraftID) err = params.AddInterface("rich_message", config.RichMessage) + if err != nil { + return params, err + } + params.AddBool("can_stop", config.CanStop) + params.AddBool("keep_on_stop", config.KeepOnStop) return params, err } @@ -573,13 +595,12 @@ func (config CopyMessagesConfig) method() string { type PhotoConfig struct { BaseFile BaseSpoiler - Thumb RequestFileData - Caption string - ParseMode string - CaptionEntities []MessageEntity - ShowCaptionAboveMedia bool - ReceiverUserID int64 - CallbackQueryID string + Thumb RequestFileData + Caption string + ParseMode string + CaptionEntities []MessageEntity + ShowCaptionAboveMedia bool + EphemeralMessageParameters *EphemeralMessageParameters } func (config PhotoConfig) params() (Params, error) { @@ -591,8 +612,9 @@ func (config PhotoConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) if err != nil { return params, err @@ -622,14 +644,13 @@ func (config PhotoConfig) files() []RequestFile { type SendLivePhotoConfig struct { BaseChat BaseSpoiler - LivePhoto RequestFileData - Photo RequestFileData - Caption string - ParseMode string - CaptionEntities []MessageEntity - ShowCaptionAboveMedia bool - ReceiverUserID int64 - CallbackQueryID string + LivePhoto RequestFileData + Photo RequestFileData + Caption string + ParseMode string + CaptionEntities []MessageEntity + ShowCaptionAboveMedia bool + EphemeralMessageParameters *EphemeralMessageParameters } func (config SendLivePhotoConfig) params() (Params, error) { @@ -641,8 +662,9 @@ func (config SendLivePhotoConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } if err = params.AddInterface("caption_entities", config.CaptionEntities); err != nil { return params, err } @@ -670,15 +692,14 @@ func (config SendLivePhotoConfig) files() []RequestFile { // AudioConfig contains information about a SendAudio request. type AudioConfig struct { BaseFile - Thumb RequestFileData - Caption string - ParseMode string - CaptionEntities []MessageEntity - Duration int - Performer string - Title string - ReceiverUserID int64 - CallbackQueryID string + Thumb RequestFileData + Caption string + ParseMode string + CaptionEntities []MessageEntity + Duration int + Performer string + Title string + EphemeralMessageParameters *EphemeralMessageParameters } func (config AudioConfig) params() (Params, error) { @@ -692,8 +713,9 @@ func (config AudioConfig) params() (Params, error) { params.AddNonEmpty("title", config.Title) params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) return params, err @@ -718,8 +740,7 @@ type DocumentConfig struct { ParseMode string CaptionEntities []MessageEntity DisableContentTypeDetection bool - ReceiverUserID int64 - CallbackQueryID string + EphemeralMessageParameters *EphemeralMessageParameters } func (config DocumentConfig) params() (Params, error) { @@ -731,8 +752,9 @@ func (config DocumentConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("disable_content_type_detection", config.DisableContentTypeDetection) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) if err != nil { return params, err @@ -757,8 +779,7 @@ type StickerConfig struct { // Emoji associated with the sticker; only for just uploaded stickers Emoji string BaseFile - ReceiverUserID int64 - CallbackQueryID string + EphemeralMessageParameters *EphemeralMessageParameters } func (config StickerConfig) params() (Params, error) { @@ -767,8 +788,9 @@ func (config StickerConfig) params() (Params, error) { return params, err } params.AddNonEmpty("emoji", config.Emoji) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } return params, err } @@ -784,19 +806,18 @@ func (config StickerConfig) files() []RequestFile { type VideoConfig struct { BaseFile BaseSpoiler - Thumb RequestFileData - Duration int - Width int - Height int - Cover RequestFileData - StartTimestamp int64 - Caption string - ParseMode string - CaptionEntities []MessageEntity - ShowCaptionAboveMedia bool - SupportsStreaming bool - ReceiverUserID int64 - CallbackQueryID string + Thumb RequestFileData + Duration int + Width int + Height int + Cover RequestFileData + StartTimestamp int64 + Caption string + ParseMode string + CaptionEntities []MessageEntity + ShowCaptionAboveMedia bool + SupportsStreaming bool + EphemeralMessageParameters *EphemeralMessageParameters } func (config VideoConfig) params() (Params, error) { @@ -813,8 +834,9 @@ func (config VideoConfig) params() (Params, error) { params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("supports_streaming", config.SupportsStreaming) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) if err != nil { return params, err @@ -845,16 +867,15 @@ func (config VideoConfig) files() []RequestFile { type AnimationConfig struct { BaseFile BaseSpoiler - Duration int - Width int - Height int - Thumb RequestFileData - Caption string - ParseMode string - CaptionEntities []MessageEntity - ShowCaptionAboveMedia bool - ReceiverUserID int64 - CallbackQueryID string + Duration int + Width int + Height int + Thumb RequestFileData + Caption string + ParseMode string + CaptionEntities []MessageEntity + ShowCaptionAboveMedia bool + EphemeralMessageParameters *EphemeralMessageParameters } func (config AnimationConfig) params() (Params, error) { @@ -869,8 +890,9 @@ func (config AnimationConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) if err != nil { return params, err @@ -899,11 +921,10 @@ func (config AnimationConfig) files() []RequestFile { // VideoNoteConfig contains information about a SendVideoNote request. type VideoNoteConfig struct { BaseFile - Thumb RequestFileData - Duration int - Length int - ReceiverUserID int64 - CallbackQueryID string + Thumb RequestFileData + Duration int + Length int + EphemeralMessageParameters *EphemeralMessageParameters } func (config VideoNoteConfig) params() (Params, error) { @@ -911,8 +932,9 @@ func (config VideoNoteConfig) params() (Params, error) { params.AddNonZero("duration", config.Duration) params.AddNonZero("length", config.Length) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } return params, err } @@ -993,13 +1015,12 @@ func (config PaidMediaConfig) inputPaidMedia() []InputMedia { // VoiceConfig contains information about a SendVoice request. type VoiceConfig struct { BaseFile - Thumb RequestFileData - Caption string - ParseMode string - CaptionEntities []MessageEntity - Duration int - ReceiverUserID int64 - CallbackQueryID string + Thumb RequestFileData + Caption string + ParseMode string + CaptionEntities []MessageEntity + Duration int + EphemeralMessageParameters *EphemeralMessageParameters } func (config VoiceConfig) params() (Params, error) { @@ -1011,8 +1032,9 @@ func (config VoiceConfig) params() (Params, error) { params.AddNonZero("duration", config.Duration) params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } err = params.AddInterface("caption_entities", config.CaptionEntities) return params, err @@ -1032,14 +1054,13 @@ func (config VoiceConfig) files() []RequestFile { // LocationConfig contains information about a SendLocation request. type LocationConfig struct { BaseChat - Latitude float64 // required - Longitude float64 // required - HorizontalAccuracy float64 // optional - LivePeriod int // optional - Heading int // optional - ProximityAlertRadius int // optional - ReceiverUserID int64 - CallbackQueryID string + Latitude float64 // required + Longitude float64 // required + HorizontalAccuracy float64 // optional + LivePeriod int // optional + Heading int // optional + ProximityAlertRadius int // optional + EphemeralMessageParameters *EphemeralMessageParameters } func (config LocationConfig) params() (Params, error) { @@ -1051,8 +1072,9 @@ func (config LocationConfig) params() (Params, error) { params.AddNonZero("live_period", config.LivePeriod) params.AddNonZero("heading", config.Heading) params.AddNonZero("proximity_alert_radius", config.ProximityAlertRadius) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } return params, err } @@ -1105,16 +1127,15 @@ func (config StopMessageLiveLocationConfig) method() string { // VenueConfig contains information about a SendVenue request. type VenueConfig struct { BaseChat - Latitude float64 // required - Longitude float64 // required - Title string // required - Address string // required - FoursquareID string - FoursquareType string - GooglePlaceID string - GooglePlaceType string - ReceiverUserID int64 - CallbackQueryID string + Latitude float64 // required + Longitude float64 // required + Title string // required + Address string // required + FoursquareID string + FoursquareType string + GooglePlaceID string + GooglePlaceType string + EphemeralMessageParameters *EphemeralMessageParameters } func (config VenueConfig) params() (Params, error) { @@ -1128,8 +1149,9 @@ func (config VenueConfig) params() (Params, error) { params.AddNonEmpty("foursquare_type", config.FoursquareType) params.AddNonEmpty("google_place_id", config.GooglePlaceID) params.AddNonEmpty("google_place_type", config.GooglePlaceType) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } return params, err } @@ -1141,12 +1163,11 @@ func (config VenueConfig) method() string { // ContactConfig allows you to send a contact. type ContactConfig struct { BaseChat - PhoneNumber string - FirstName string - LastName string - VCard string - ReceiverUserID int64 - CallbackQueryID string + PhoneNumber string + FirstName string + LastName string + VCard string + EphemeralMessageParameters *EphemeralMessageParameters } func (config ContactConfig) params() (Params, error) { @@ -1157,8 +1178,9 @@ func (config ContactConfig) params() (Params, error) { params.AddNonEmpty("last_name", config.LastName) params.AddNonEmpty("vcard", config.VCard) - params.AddNonZero64("receiver_user_id", config.ReceiverUserID) - params.AddNonEmpty("callback_query_id", config.CallbackQueryID) + if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + return params, err + } return params, err } @@ -1498,12 +1520,13 @@ func (config EditMessageReplyMarkupConfig) method() string { return "editMessageReplyMarkup" } -// EditEphemeralMessageTextConfig edits an ephemeral text message. +// EditEphemeralMessageTextConfig edits an ephemeral text or rich message. type EditEphemeralMessageTextConfig struct { BaseEphemeralMessage Text string ParseMode string Entities []MessageEntity + RichMessage *InputRichMessage LinkPreviewOptions LinkPreviewOptions ReplyMarkup *InlineKeyboardMarkup } @@ -1514,11 +1537,17 @@ func (config EditEphemeralMessageTextConfig) params() (Params, error) { return params, err } - params["text"] = config.Text + params.AddNonEmpty("text", config.Text) params.AddNonEmpty("parse_mode", config.ParseMode) if err = params.AddInterface("entities", config.Entities); err != nil { return params, err } + if config.RichMessage != nil { + prepared := prepareInputRichMessageForParams(*config.RichMessage) + if err = params.AddInterface("rich_message", prepared); err != nil { + return params, err + } + } if err = params.AddInterfaceNonZero("link_preview_options", config.LinkPreviewOptions); err != nil { return params, err } @@ -1531,6 +1560,13 @@ func (EditEphemeralMessageTextConfig) method() string { return "editEphemeralMessageText" } +func (config EditEphemeralMessageTextConfig) files() []RequestFile { + if config.RichMessage == nil { + return nil + } + return prepareInputRichMessageForFiles(*config.RichMessage) +} + // EditEphemeralMessageMediaConfig edits the media of an ephemeral message. type EditEphemeralMessageMediaConfig struct { BaseEphemeralMessage @@ -1544,7 +1580,8 @@ func (config EditEphemeralMessageMediaConfig) params() (Params, error) { return params, err } - if err = params.AddInterface("media", config.Media); err != nil { + preparedMedia := prepareInputMediaForParams([]InputMedia{config.Media}) + if err = params.AddInterface("media", preparedMedia[0]); err != nil { return params, err } err = params.AddInterface("reply_markup", config.ReplyMarkup) @@ -1556,13 +1593,18 @@ func (EditEphemeralMessageMediaConfig) method() string { return "editEphemeralMessageMedia" } +func (config EditEphemeralMessageMediaConfig) files() []RequestFile { + return prepareInputMediaForFiles([]InputMedia{config.Media}) +} + // EditEphemeralMessageCaptionConfig edits the caption of an ephemeral message. type EditEphemeralMessageCaptionConfig struct { BaseEphemeralMessage - Caption string - ParseMode string - CaptionEntities []MessageEntity - ReplyMarkup *InlineKeyboardMarkup + Caption string + ParseMode string + CaptionEntities []MessageEntity + ShowCaptionAboveMedia bool + ReplyMarkup *InlineKeyboardMarkup } func (config EditEphemeralMessageCaptionConfig) params() (Params, error) { @@ -1576,6 +1618,7 @@ func (config EditEphemeralMessageCaptionConfig) params() (Params, error) { if err = params.AddInterface("caption_entities", config.CaptionEntities); err != nil { return params, err } + params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) err = params.AddInterface("reply_markup", config.ReplyMarkup) return params, err @@ -2262,6 +2305,7 @@ type PromoteChatMemberConfig struct { CanManageTopics bool CanManageDirectMessages bool CanManageTags bool + CanSendWelcomeMessages bool } func (config PromoteChatMemberConfig) method() string { @@ -2291,6 +2335,7 @@ func (config PromoteChatMemberConfig) params() (Params, error) { params.AddBool("can_manage_topics", config.CanManageTopics) params.AddBool("can_manage_direct_messages", config.CanManageDirectMessages) params.AddBool("can_manage_tags", config.CanManageTags) + params.AddBool("can_send_welcome_messages", config.CanSendWelcomeMessages) return params, nil } diff --git a/docs/examples/bot-api-10.md b/docs/examples/bot-api-10.md index dec99830..49b74a2a 100644 --- a/docs/examples/bot-api-10.md +++ b/docs/examples/bot-api-10.md @@ -39,7 +39,7 @@ richMessage.Media = []tgbotapi.InputRichMessageMedia{ _, err := bot.SendRichMessage(tgbotapi.NewSendRichMessage(chatID, richMessage)) ``` -Exactly one of `HTML`, `Markdown`, or `Blocks` must be used. Direct upload of new files is not available for `SendRichMessageDraftConfig`, inline `EditMessageTextConfig`, or `EditEphemeralMessageMediaConfig`; use a Telegram `file_id` or an HTTP URL there. +Exactly one of `HTML`, `Markdown`, or `Blocks` must be used. Direct upload of new files is not available for `SendRichMessageDraftConfig` or inline `EditMessageTextConfig`; use a Telegram `file_id` or an HTTP URL there. ## Ephemeral Commands and Messages @@ -58,7 +58,9 @@ An incoming ephemeral command has `Message.MessageID == 0` and a separate `Messa ```go incoming := update.Message reply := tgbotapi.NewMessage(incoming.Chat.ID, "Checking...") -reply.ReceiverUserID = incoming.From.ID +reply.EphemeralMessageParameters = &tgbotapi.EphemeralMessageParameters{ + ReceiverUserID: incoming.From.ID, +} reply.ReplyParameters.EphemeralMessageID = incoming.EphemeralMessageID sent, err := bot.Send(reply) @@ -74,9 +76,9 @@ if err != nil { } ``` -For a callback-query-triggered response, set `CallbackQueryID` on the selected send config instead of replying to an ephemeral message. Non-administrator bots must respond within 15 seconds of the eligible action. Telegram does not guarantee delivery, especially when the receiver is offline. +For a callback-query-triggered response, set `EphemeralMessageParameters.CallbackQueryID` on the selected send config instead of replying to an ephemeral message. Non-administrator bots must respond within 15 seconds of the eligible action. Telegram does not guarantee delivery, especially when the receiver is offline. -The edit and delete methods require all three identifiers: chat, receiver user, and ephemeral message. `EditEphemeralMessageCaption` accepts an empty caption to remove it. New uploads are not supported by `EditEphemeralMessageMedia`. +The edit and delete methods require all three identifiers: chat, receiver user, and ephemeral message. `EditEphemeralMessageCaption` accepts an empty caption to remove it. `EditEphemeralMessageMedia` supports uploading new files. ## Subscription and Community Updates diff --git a/examples/bot_api_10.go b/examples/bot_api_10.go index a89f8b25..b115bf0d 100644 --- a/examples/bot_api_10.go +++ b/examples/bot_api_10.go @@ -138,7 +138,9 @@ func handle_bot_api_10_2_update(bot *api.BotAPI, update api.Update) { } reply := api.NewMessage(update.Message.Chat.ID, "Checking...") - reply.ReceiverUserID = update.Message.From.ID + reply.EphemeralMessageParameters = &api.EphemeralMessageParameters{ + ReceiverUserID: update.Message.From.ID, + } reply.ReplyParameters.EphemeralMessageID = update.Message.EphemeralMessageID sent, err := bot.Send(reply) if err != nil { diff --git a/helper_methods.go b/helper_methods.go index a7db8d55..a88ccb25 100644 --- a/helper_methods.go +++ b/helper_methods.go @@ -924,11 +924,13 @@ func newBaseEphemeralMessage(chatID, receiverUserID int64, ephemeralMessageID in // NewEphemeralMessage creates a text message visible only to receiverUserID. // // Bot administrators can send the returned config directly. Other bots must -// additionally set CallbackQueryID or ReplyParameters.EphemeralMessageID -// within 15 seconds of an eligible action. +// additionally set EphemeralMessageParameters.CallbackQueryID or +// ReplyParameters.EphemeralMessageID within 15 seconds of an eligible action. func NewEphemeralMessage(chatID, receiverUserID int64, text string) MessageConfig { config := NewMessage(chatID, text) - config.ReceiverUserID = receiverUserID + config.EphemeralMessageParameters = &EphemeralMessageParameters{ + ReceiverUserID: receiverUserID, + } return config } diff --git a/parity_interfaces_test.go b/parity_interfaces_test.go index 4c4541e4..260a4204 100644 --- a/parity_interfaces_test.go +++ b/parity_interfaces_test.go @@ -57,6 +57,8 @@ var ( _ Fileable = SendLivePhotoConfig{} _ Fileable = SendRichMessageConfig{} _ Fileable = EditMessageTextConfig{} + _ Fileable = EditEphemeralMessageTextConfig{} + _ Fileable = EditEphemeralMessageMediaConfig{} _ Fileable = SendPollConfig{} _ Fileable = SetBusinessAccountProfilePhotoConfig{} _ Fileable = PostStoryConfig{} diff --git a/types.go b/types.go index 7cb064a1..e06b1e3e 100644 --- a/types.go +++ b/types.go @@ -167,6 +167,10 @@ type Update struct { // // optional Subscription *BotSubscriptionUpdated `json:"subscription,omitempty"` + // StoppedMessageGeneration is emitted when a user stops message generation. + // + // optional + StoppedMessageGeneration *MessageGenerationStopped `json:"stopped_message_generation,omitempty"` } // SentFrom returns the user who sent an update. Can be nil, if Telegram did not provide information @@ -267,6 +271,8 @@ func (u *Update) FromChat() *Chat { return &u.ChatBoost.Chat case u.ChatBoostRemoved != nil: return &u.ChatBoostRemoved.Chat + case u.StoppedMessageGeneration != nil: + return &u.StoppedMessageGeneration.Chat default: return nil } @@ -1089,6 +1095,10 @@ type Message struct { // // optional CommunityChatAdded *CommunityChatAdded `json:"community_chat_added,omitempty"` + // CommunityChatJoined is a service message about a chat being joined from a community. + // + // optional + CommunityChatJoined *CommunityChatJoined `json:"community_chat_joined,omitempty"` // CommunityChatRemoved is a service message about a chat being removed from a community. // // optional @@ -2784,6 +2794,12 @@ type RichTextBotCommand struct { BotCommand string `json:"bot_command"` } +// RichTextButton describes a rich text button. +type RichTextButton struct { + Type string `json:"type"` + Button RichMessageButton `json:"button"` +} + // RichTextAnchor describes an anchor. type RichTextAnchor struct { Type string `json:"type"` @@ -2896,6 +2912,13 @@ type RichBlockBlockQuotation struct { Credit RichText `json:"credit,omitempty"` } +// RichBlockExpandableBlockQuotation describes an expandable block quotation. +type RichBlockExpandableBlockQuotation struct { + Type string `json:"type"` + Text RichText `json:"text"` + Credit RichText `json:"credit,omitempty"` +} + // RichBlockPullQuotation describes a pull quotation. type RichBlockPullQuotation struct { Type string `json:"type"` @@ -2923,6 +2946,7 @@ type RichBlockTable struct { Cells [][]RichBlockTableCell `json:"cells"` IsBordered bool `json:"is_bordered,omitempty"` IsStriped bool `json:"is_striped,omitempty"` + IsCompact bool `json:"is_compact,omitempty"` Caption RichText `json:"caption,omitempty"` } @@ -2944,6 +2968,13 @@ type RichBlockMap struct { Caption *RichBlockCaption `json:"caption,omitempty"` } +// RichBlockButtons describes a row of rich message buttons. +type RichBlockButtons struct { + Type string `json:"type"` + Buttons []RichMessageButton `json:"buttons"` + Align string `json:"align,omitempty"` +} + // RichBlockAnimation describes an animation block. type RichBlockAnimation struct { Type string `json:"type"` @@ -2959,6 +2990,13 @@ type RichBlockAudio struct { Caption *RichBlockCaption `json:"caption,omitempty"` } +// RichBlockDocument describes a document block. +type RichBlockDocument struct { + Type string `json:"type"` + Document Document `json:"document"` + Caption *RichBlockCaption `json:"caption,omitempty"` +} + // RichBlockPhoto describes a photo block. type RichBlockPhoto struct { Type string `json:"type"` @@ -3056,6 +3094,13 @@ type InputRichBlockBlockQuotation struct { Credit RichText `json:"credit,omitempty"` } +// InputRichBlockExpandableBlockQuotation describes an outgoing expandable block quotation. +type InputRichBlockExpandableBlockQuotation struct { + Type string `json:"type"` + Text RichText `json:"text"` + Credit RichText `json:"credit,omitempty"` +} + // InputRichBlockPullQuotation describes an outgoing pull quotation. type InputRichBlockPullQuotation struct { Type string `json:"type"` @@ -3083,6 +3128,7 @@ type InputRichBlockTable struct { Cells [][]RichBlockTableCell `json:"cells"` IsBordered bool `json:"is_bordered,omitempty"` IsStriped bool `json:"is_striped,omitempty"` + IsCompact bool `json:"is_compact,omitempty"` Caption RichText `json:"caption,omitempty"` } @@ -3104,6 +3150,13 @@ type InputRichBlockMap struct { Caption *RichBlockCaption `json:"caption,omitempty"` } +// InputRichBlockButtons describes an outgoing row of rich message buttons. +type InputRichBlockButtons struct { + Type string `json:"type"` + Buttons []RichMessageButton `json:"buttons"` + Align string `json:"align,omitempty"` +} + // InputRichBlockAnimation describes an outgoing animation block. type InputRichBlockAnimation struct { Type string `json:"type"` @@ -3118,6 +3171,13 @@ type InputRichBlockAudio struct { Caption *RichBlockCaption `json:"caption,omitempty"` } +// InputRichBlockDocument describes an outgoing document block. +type InputRichBlockDocument struct { + Type string `json:"type"` + Document InputMediaDocument `json:"document"` + Caption *RichBlockCaption `json:"caption,omitempty"` +} + // InputRichBlockPhoto describes an outgoing photo block. type InputRichBlockPhoto struct { Type string `json:"type"` @@ -3228,6 +3288,11 @@ type ReplyKeyboardMarkup struct { // // optional Selective bool `json:"selective,omitempty"` + // ForceReply shows reply interface to the user, as if they manually + // selected the bot's message and tapped 'Reply'. + // + // optional + ForceReply bool `json:"force_reply,omitempty"` } // KeyboardButton represents one button of the reply keyboard. For simple text @@ -3437,6 +3502,12 @@ type InlineKeyboardMarkup struct { // InlineKeyboard array of button rows, each represented by an Array of // InlineKeyboardButton objects InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` + // ForceReply shows reply interface to the user, as if they manually + // selected the bot's message and tapped 'Reply'. The value of the field + // can't be changed when the inline keyboard is edited. + // + // optional + ForceReply bool `json:"force_reply,omitempty"` } // InlineKeyboardButton represents one button of an inline keyboard. You must @@ -3524,6 +3595,10 @@ type InlineKeyboardButton struct { // // optional Pay bool `json:"pay,omitempty"` + // Disabled marks the button as disabled so it does nothing. + // + // optional + Disabled *DisabledButton `json:"disabled,omitempty"` } // LoginURL represents a parameter of the inline keyboard button used to @@ -3725,6 +3800,7 @@ type ChatAdministratorRights struct { CanManageTopics bool `json:"can_manage_topics"` CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` CanManageTags bool `json:"can_manage_tags,omitempty"` + CanSendWelcomeMessages bool `json:"can_send_welcome_messages,omitempty"` } // ChatMember contains information about one member of a chat. @@ -3916,6 +3992,11 @@ type ChatMember struct { // // optional CanManageTags bool `json:"can_manage_tags,omitempty"` + // CanSendWelcomeMessages True, if the administrator can manage chat welcome + // messages or directly send them in the case of bots. + // + // optional + CanSendWelcomeMessages bool `json:"can_send_welcome_messages,omitempty"` // CanEditTag True, if the user is allowed to edit their own tag. // // optional @@ -7082,9 +7163,46 @@ type CommunityChatAdded struct { Community Community `json:"community"` } +// CommunityChatJoined describes a chat being joined by a user from a community. +type CommunityChatJoined struct { + Community Community `json:"community"` +} + // CommunityChatRemoved describes a chat being removed from a community. type CommunityChatRemoved struct{} +// MessageGenerationStopped describes an update about a user stopping message generation. +type MessageGenerationStopped struct { + Chat Chat `json:"chat"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DraftID int `json:"draft_id"` +} + +// EphemeralMessageParameters describes parameters of an ephemeral message to send. +type EphemeralMessageParameters struct { + ReceiverUserID int64 `json:"receiver_user_id"` + CallbackQueryID string `json:"callback_query_id,omitempty"` + ReplaceCallbackQueryMessage bool `json:"replace_callback_query_message,omitempty"` +} + +// DisabledButton represents a disabled button which does nothing. +type DisabledButton struct{} + +// RichMessageButton represents a button in a RichMessage. +type RichMessageButton struct { + Text RichText `json:"text"` + Style string `json:"style,omitempty"` + URL string `json:"url,omitempty"` + CallbackData string `json:"callback_data,omitempty"` + WebApp *WebAppInfo `json:"web_app,omitempty"` + LoginURL *LoginURL `json:"login_url,omitempty"` + SwitchInlineQuery *string `json:"switch_inline_query,omitempty"` + SwitchInlineQueryCurrentChat *string `json:"switch_inline_query_current_chat,omitempty"` + SwitchInlineQueryChosenChat *SwitchInlineQueryChosenChat `json:"switch_inline_query_chosen_chat,omitempty"` + CopyText *CopyTextButton `json:"copy_text,omitempty"` + Disabled *DisabledButton `json:"disabled,omitempty"` +} + // PollOptionAdded describes a service message about an option added to a poll. type PollOptionAdded struct { PollMessage *MaybeInaccessibleMessage `json:"poll_message,omitempty"` @@ -7389,13 +7507,16 @@ type UniqueGift struct { // UniqueGiftInfo describes information about a unique gift in a message. type UniqueGiftInfo struct { - Gift UniqueGift `json:"gift"` - Origin string `json:"origin"` - LastResaleCurrency string `json:"last_resale_currency,omitempty"` - LastResaleAmount int `json:"last_resale_amount,omitempty"` - OwnedGiftID string `json:"owned_gift_id,omitempty"` - TransferStarCount int `json:"transfer_star_count,omitempty"` - NextTransferDate int64 `json:"next_transfer_date,omitempty"` + Gift UniqueGift `json:"gift"` + Origin string `json:"origin"` + Text string `json:"text,omitempty"` + Entities []MessageEntity `json:"entities,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + LastResaleCurrency string `json:"last_resale_currency,omitempty"` + LastResaleAmount int `json:"last_resale_amount,omitempty"` + OwnedGiftID string `json:"owned_gift_id,omitempty"` + TransferStarCount int `json:"transfer_star_count,omitempty"` + NextTransferDate int64 `json:"next_transfer_date,omitempty"` } // UserProfileAudios contains audios displayed on user profile. diff --git a/upload.go b/upload.go index e1578977..42fc2cd3 100644 --- a/upload.go +++ b/upload.go @@ -328,6 +328,16 @@ func prepareInputRichBlock(block InputRichBlock, name string, plan *uploadPlan) prepared := *current prepareInputMediaItem(&prepared.Audio, name, plan) return &prepared + case InputRichBlockDocument: + prepareInputMediaItem(¤t.Document, name, plan) + return current + case *InputRichBlockDocument: + if current == nil { + return current + } + prepared := *current + prepareInputMediaItem(&prepared.Document, name, plan) + return &prepared case InputRichBlockPhoto: prepareInputMediaItem(¤t.Photo, name, plan) return current From 2d9d974cdda0745b5c4e403127c0911ca33c88d5 Mon Sep 17 00:00:00 2001 From: OvyFlash Date: Mon, 24 Aug 2026 23:39:18 +0300 Subject: [PATCH 2/5] Address PR review: type docs, ephemeral param shape, drop .cursor Add Telegram API field comments and align/style helpers on 10.3 types, use EphemeralMessageParameters as a value with AddInterfaceNonZero to match LinkPreviewOptions, gitignore .cursor agent config, and remove it from the tree. Co-authored-by: Cursor --- .cursor/rules/telegram-bot-api-updates.mdc | 69 ----- ...telegram-bot-api-verification-pipeline.mdc | 61 ----- .cursor/rules/telegram-type-patterns.mdc | 87 ------ .gitignore | 1 + bot_api_10_2_test.go | 2 +- bot_api_10_3_test.go | 23 +- configs.go | 56 ++-- docs/examples/bot-api-10.md | 2 +- examples/bot_api_10.go | 2 +- helper_methods.go | 2 +- types.go | 250 ++++++++++++++---- 11 files changed, 258 insertions(+), 297 deletions(-) delete mode 100644 .cursor/rules/telegram-bot-api-updates.mdc delete mode 100644 .cursor/rules/telegram-bot-api-verification-pipeline.mdc delete mode 100644 .cursor/rules/telegram-type-patterns.mdc diff --git a/.cursor/rules/telegram-bot-api-updates.mdc b/.cursor/rules/telegram-bot-api-updates.mdc deleted file mode 100644 index ea96a15a..00000000 --- a/.cursor/rules/telegram-bot-api-updates.mdc +++ /dev/null @@ -1,69 +0,0 @@ ---- -description: How Telegram Bot API version updates are applied in this library -alwaysApply: true ---- - -# Telegram Bot API Update Workflow - -## Source of truth - -1. Official changelog: https://core.telegram.org/bots/api-changelog -2. Method/type docs: https://core.telegram.org/bots/api -3. Parity schema (CI): `https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/main/api.json` → local gitignored `api.json` - -Implement **one Bot API version per PR/branch**. Branch: `bot-api-X.Y`. Do not skip versions. - -## File map (touch only what the changelog needs) - -| Area | File | What to add | -|------|------|-------------| -| Types / Update fields | `types.go` | New types; fields on `Update`, `Message`, etc. with `json:"snake_case,omitempty"` | -| Request configs | `configs.go` | `*Config` + `method()` + `params()` (+ `files()` if uploads) | -| Shared bases | `helper_structs.go` | Embeddable bases (`BaseChat`, `BaseEdit`, `BaseEphemeralMessage`, …) | -| Helpers | `helper_methods.go` | `New*` constructors for required params | -| Uploads | `upload.go` | Recursive attach/`file_id` logic for media-heavy configs | -| Convenience API | `bot.go` | Typed wrappers when return ≠ `Message` via `Send` (or bool/string/etc.) | -| Compile checks | `parity_interfaces_test.go` | `_ Chattable = …{}` / `_ Fileable = …{}` | -| Tests | `configs_test.go`, `types_test.go`, optional `bot_api_X_Y_test.go` | Params/JSON/unmarshal coverage | -| Docs/examples | `docs/examples/`, `examples/` | Only for major user-facing features | - -## Naming & patterns - -- Config: PascalCase of API method + `Config` (`sendRichMessage` → `SendRichMessageConfig`). -- Helper: `New` + Config name without suffix (`NewSendRichMessage`). -- Bot method: PascalCase API method (`EditEphemeralMessageText`). -- Prefer embedding bases over duplicating chat/ephemeral/edit fields. -- `params()`: start from embedded base, then `Params` helpers (`AddNonEmpty`, `AddNonZero`, `AddNonZero64`, `AddBool`, `AddFirstValid`, `AddInterface`, `Merge`). -- Chat targeting: `ChatID int64` + `ChannelUsername` (via `ChatConfig` / `BaseChat`), never invent alternate ID shapes. -- New `Update` fields: add JSON field + extend `SentFrom` / `FromChat` switch arms when applicable. -- **Docs field order:** keep struct fields / new types / method params in the same order as on core.telegram.org/bots/api (legacy code often doesn’t; don’t churn old order unless already editing that struct—see `telegram-type-patterns` rule). -- No comments unless behavior is non-obvious; match surrounding style (tabs in Go). - -## Validation order - -1. Mirror changelog bullets completely (types → fields → methods → params). -2. `go vet ./...` (do not use full `go build` for intermediate checks). -3. Unit tests for new params/JSON. -4. Optional: fetch `api.json`, run `go test -tags=api_parity ./...`; only extend allowlists in `api_parity_test.go` for documented legacy/promoted extras. - -## Commit / PR style (from history) - -- Message: `Add Telegram Bot API X.Y support` or `Bot API X.Y: `. -- Follow-up fixes for the same version are separate small PRs (e.g. missing ephemeral params). -- Keep parity/serialization fixes separate from the version bump when possible. - -## Version baseline (do not confuse tag ≠ master) - -| Signal | Value | -|--------|--------| -| Latest **git tag** | `v9.4.0` (tags lag; README discourages tagging) | -| Latest **code on `master`** | Bot API **10.3** present | -| Latest **explicit version commit** | Bot API 10.3 implementation (this branch) | -| Next official | — track changelog after 10.3 | - -Notes: - -- **8.3 is not current** on `master` (that landing was `61bd317` / PR #65, 2025-05). -- No commits titled 9.0–9.3 or 10.1; those features were bundled (`2f2184e` ≈ 9.0–9.4 catch-up; `c20b6c3` ≈ 10.1). -- Always judge “implemented” by **types/methods on `master`**, not by the newest tag or a single commit title. -- Coverage gate: follow `telegram-bot-api-verification-pipeline` (curated audit: 9.0–10.3 OK after 10.3 landing). diff --git a/.cursor/rules/telegram-bot-api-verification-pipeline.mdc b/.cursor/rules/telegram-bot-api-verification-pipeline.mdc deleted file mode 100644 index 87ca6343..00000000 --- a/.cursor/rules/telegram-bot-api-verification-pipeline.mdc +++ /dev/null @@ -1,61 +0,0 @@ ---- -description: Pipeline to verify Bot API version coverage before/during implementation -alwaysApply: true ---- - -# Bot API Verification Pipeline - -Run this before choosing a version bump and again before declaring a version done. - -## A. Establish baseline - -1. Read https://core.telegram.org/bots/api-changelog from last implemented version → target. -2. Treat **`master` types/methods** as truth (not git tags). Current implemented ceiling: **10.3**. -3. Skip Mini App **client-only** APIs (`WebApp.hideKeyboard`, DeviceStorage, etc.) — not HTTP Bot API. - -## B. Changelog → checklist - -For each version bullet, classify: - -| Kind | How to verify in repo | -|------|------------------------| -| New class | `type Name` or alias `Name =` in `types.go` | -| New method | `method()` returns `"camelCaseName"` in a `*Config` | -| New field | `json:"snake_case` on the owning struct (prefer docs field order) | -| New parameter | serialized in that config’s `params()` / embedded base | -| Replacement | old name gone or migrated; new name present | -| Update payload | field on `Update`/`Message` + `SentFrom`/`FromChat` if needed | - -Ignore regex false positives (`allowing`, `and`, `from` from changelog prose). - -## C. Commands (local) - -```bash -go vet ./... -go test -count=1 ./... -curl -fsSL -o api.json \ - https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/main/api.json -go test -tags=api_parity -run '^TestAPIParity' -count=1 . -``` - -`api.json` is gitignored. Spec may lag a fresh Telegram release by hours/days — changelog wins if they disagree. - -## D. Pattern gates (must pass for new code) - -1. Embed bases (`BaseChat`, `BaseEphemeralMessage`, …) — see `telegram-type-patterns`. -2. Inbound unions flattened (`TransactionPartner`-style), not interface JSON. -3. Field/param order follows core.telegram.org/bots/api for **new** fields. -4. Helpers `New*`, `parity_interfaces_test.go` compile checks, tests for params/JSON. -5. Breaking renames (e.g. 10.3 `ephemeral_message_parameters`): update configs + helpers + tests + examples together. - -## E. Decision rule - -- If any **prior** version on the checklist has gaps → implement the **oldest** gapped version first. -- Else implement the **next** changelog version only (one version per branch/PR). -- After merge, update the Version baseline table in `telegram-bot-api-updates.mdc`. - -## F. Post-8.3 audit snapshot - -| Version | Curated checklist | -|---------|-------------------| -| 9.0–10.3 | Present on `master` (as of Bot API 10.3 landing) | diff --git a/.cursor/rules/telegram-type-patterns.mdc b/.cursor/rules/telegram-type-patterns.mdc deleted file mode 100644 index 786098c7..00000000 --- a/.cursor/rules/telegram-type-patterns.mdc +++ /dev/null @@ -1,87 +0,0 @@ ---- -description: Embedding and Telegram polymorphic type patterns (TransactionPartner-style) -globs: "{types.go,configs.go,helper_structs.go,helper_methods.go,upload.go}" -alwaysApply: false ---- - -# Type & Embedding Patterns - -Reuse these shapes for new Bot API types. Do not invent interface-based JSON unions for inbound Telegram objects. - -## 0) Field & type order (docs order) - -Match https://core.telegram.org/bots/api field order as closely as practical: - -- Struct fields: same order as listed on the type/method page (required first as Telegram lists them, then optionals in doc order). -- New types in a version bump: introduce them in changelog / docs appearance order when adding a block of related types. -- Method params in `*Config` / `params()`: follow the Bot API parameter table order after embedded base fields. -- Do **not** mass-reorder old structs just to fix legacy drift; previous authors often ignored this. Apply docs order for **new** fields/types and when you already touch a struct for an API change (place the new field where the docs put it). - -## 1) Config embedding (requests) - -Shared request fields live in `helper_structs.go` and are **embedded** into configs: - -| Base | Use for | -|------|---------| -| `ChatConfig` | chat_id / channel username | -| `BaseChat` | outgoing chat messages (reply, protect, effects, …) | -| `BaseChatMessage` / `BaseChatMessages` | edit/delete by message id(s) | -| `BaseEdit` | inline-or-chat edits | -| `BaseFile` | single-file sends | -| `BaseEphemeralMessage` | ephemeral edit/delete | -| `BaseInputMedia` | `InputMedia*` variants | - -```go -type EditEphemeralMessageTextConfig struct { - BaseEphemeralMessage - Text string - // ... -} - -func (c EditEphemeralMessageTextConfig) params() (Params, error) { - params, err := c.BaseEphemeralMessage.params() - // add only this config's fields - return params, err -} -``` - -Never copy-paste the same chat/ephemeral fields into each config. - -## 2) Flattened polymorphic types (responses) - -Telegram docs list exclusive variants (`TransactionPartnerUser`, `PaidMediaPhoto`, …). In this library, **inbound** unions are one struct with a discriminator + optional variant fields: - -```go -type TransactionPartner struct { - Type string `json:"type"` - User User `json:"user,omitempty"` // "user" - Chat Chat `json:"chat,omitempty"` // "chat" - WithdrawalState *RevenueWithdrawalState `json:"withdrawal_state,omitempty"` // "fragment" - RequestCount int `json:"request_count,omitempty"` // "telegram_api" - // ... -} -``` - -Same pattern: `ChatMember`, `MessageOrigin`, `ReactionType`, `PaidMedia`, `ChatBoostSource`, `RevenueWithdrawalState`, `BackgroundType`, `StoryAreaType`. - -Rules: - -- Discriminator: `Type` or `Source` / `Status` as Telegram names it. -- Variant-only fields: `omitempty` + comment which variant(s) use them. -- Optional helpers: `IsUser()`, `IsEmoji()`, … -- Type-name constants when needed for parity (`MessageOriginUser = "user"`). -- At file end, add **aliases** so Telegram type names compile: `TransactionPartnerOther = TransactionPartner`, `PaidMediaPhoto = PaidMedia`. -- Dedicated variant structs (`TransactionPartnerUser`) are for **docs/constructors/outbound clarity**, not separate JSON unmarshal targets for that union. - -## 3) Outbound interfaces (when needed) - -Use small interfaces only for **sending** polymorphic payloads the bot builds: - -- `InputMedia` (+ `BaseInputMedia` embed) -- `InputProfilePhoto`, `InputStoryContent`, `InlineQueryResults` - -Prefer concrete structs with `Type` set in `New*` helpers. - -## 4) Update / Message field wiring - -New update payload → field on `Update` / `Message` with `json:"snake,omitempty"`, then extend `(*Update).SentFrom` / `FromChat` if a user/chat is primary. diff --git a/.gitignore b/.gitignore index 9585d150..1db48e85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ coverage.out tmp/ book/ .vscode/ +.cursor/ CLAUDE.md api.json \ No newline at end of file diff --git a/bot_api_10_2_test.go b/bot_api_10_2_test.go index ad471323..e8971759 100644 --- a/bot_api_10_2_test.go +++ b/bot_api_10_2_test.go @@ -180,7 +180,7 @@ func TestBotAPI102NewEphemeralMessageDirectAdminParams(t *testing.T) { } func TestBotAPI102EphemeralSendParams(t *testing.T) { - ephemeral := &EphemeralMessageParameters{ReceiverUserID: 42, CallbackQueryID: "callback"} + ephemeral := EphemeralMessageParameters{ReceiverUserID: 42, CallbackQueryID: "callback"} message := NewMessage(1, "text") message.EphemeralMessageParameters = ephemeral animation := NewAnimation(1, FileID("animation")) diff --git a/bot_api_10_3_test.go b/bot_api_10_3_test.go index 236f2216..11b6a523 100644 --- a/bot_api_10_3_test.go +++ b/bot_api_10_3_test.go @@ -8,7 +8,7 @@ import ( func TestBotAPI103EphemeralMessageParameters(t *testing.T) { config := NewSendRichMessage(1, NewInputRichMessageHTML("

hi

")) - config.EphemeralMessageParameters = &EphemeralMessageParameters{ + config.EphemeralMessageParameters = EphemeralMessageParameters{ ReceiverUserID: 42, CallbackQueryID: "cbq", ReplaceCallbackQueryMessage: true, @@ -120,7 +120,7 @@ func TestBotAPI103NewTypesJSON(t *testing.T) { }, { name: "buttons block", - value: RichBlockButtons{Type: "buttons", Buttons: []RichMessageButton{{Text: "A", CallbackData: "a"}}, Align: "center"}, + value: RichBlockButtons{Type: "buttons", Buttons: []RichMessageButton{{Text: "A", CallbackData: "a"}}, Align: RichBlockButtonsAlignCenter}, want: `"align":"center"`, }, { @@ -200,6 +200,25 @@ func TestBotAPI103NewTypesJSON(t *testing.T) { } } +func TestBotAPI103RichBlockButtonsAlign(t *testing.T) { + block := RichBlockButtons{Align: RichBlockButtonsAlignCenter} + if !block.Center() || block.Left() || block.Right() { + t.Fatalf("unexpected align helpers: %#v", block) + } + + input := InputRichBlockButtons{Align: RichBlockButtonsAlignRight} + if !input.Right() || input.Left() || input.Center() { + t.Fatalf("unexpected input align helpers: %#v", input) + } +} + +func TestBotAPI103RichMessageButtonStyle(t *testing.T) { + button := RichMessageButton{Style: RichMessageButtonStylePrimary} + if !button.Primary() || button.Danger() || button.LinkStyle() { + t.Fatalf("unexpected style helpers: %#v", button) + } +} + func TestBotAPI103UpdateStoppedMessageGeneration(t *testing.T) { var update Update if err := json.Unmarshal([]byte(`{ diff --git a/configs.go b/configs.go index ca9abbe3..ab16f025 100644 --- a/configs.go +++ b/configs.go @@ -316,7 +316,7 @@ type MessageConfig struct { ParseMode string Entities []MessageEntity LinkPreviewOptions LinkPreviewOptions - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config MessageConfig) params() (Params, error) { @@ -327,7 +327,7 @@ func (config MessageConfig) params() (Params, error) { params["text"] = config.Text params.AddNonEmpty("parse_mode", config.ParseMode) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("entities", config.Entities) @@ -409,7 +409,7 @@ func (config SendMessageDraftConfig) params() (Params, error) { type SendRichMessageConfig struct { BaseChat RichMessage InputRichMessage - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config SendRichMessageConfig) method() string { @@ -427,7 +427,7 @@ func (config SendRichMessageConfig) params() (Params, error) { if err != nil { return params, err } - err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters) + err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters) return params, err } @@ -600,7 +600,7 @@ type PhotoConfig struct { ParseMode string CaptionEntities []MessageEntity ShowCaptionAboveMedia bool - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config PhotoConfig) params() (Params, error) { @@ -612,7 +612,7 @@ func (config PhotoConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -650,7 +650,7 @@ type SendLivePhotoConfig struct { ParseMode string CaptionEntities []MessageEntity ShowCaptionAboveMedia bool - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config SendLivePhotoConfig) params() (Params, error) { @@ -662,7 +662,7 @@ func (config SendLivePhotoConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } if err = params.AddInterface("caption_entities", config.CaptionEntities); err != nil { @@ -699,7 +699,7 @@ type AudioConfig struct { Duration int Performer string Title string - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config AudioConfig) params() (Params, error) { @@ -713,7 +713,7 @@ func (config AudioConfig) params() (Params, error) { params.AddNonEmpty("title", config.Title) params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -740,7 +740,7 @@ type DocumentConfig struct { ParseMode string CaptionEntities []MessageEntity DisableContentTypeDetection bool - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config DocumentConfig) params() (Params, error) { @@ -752,7 +752,7 @@ func (config DocumentConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("disable_content_type_detection", config.DisableContentTypeDetection) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -779,7 +779,7 @@ type StickerConfig struct { // Emoji associated with the sticker; only for just uploaded stickers Emoji string BaseFile - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config StickerConfig) params() (Params, error) { @@ -788,7 +788,7 @@ func (config StickerConfig) params() (Params, error) { return params, err } params.AddNonEmpty("emoji", config.Emoji) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } return params, err @@ -817,7 +817,7 @@ type VideoConfig struct { CaptionEntities []MessageEntity ShowCaptionAboveMedia bool SupportsStreaming bool - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config VideoConfig) params() (Params, error) { @@ -834,7 +834,7 @@ func (config VideoConfig) params() (Params, error) { params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("supports_streaming", config.SupportsStreaming) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -875,7 +875,7 @@ type AnimationConfig struct { ParseMode string CaptionEntities []MessageEntity ShowCaptionAboveMedia bool - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config AnimationConfig) params() (Params, error) { @@ -890,7 +890,7 @@ func (config AnimationConfig) params() (Params, error) { params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) params.AddBool("show_caption_above_media", config.ShowCaptionAboveMedia) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -924,7 +924,7 @@ type VideoNoteConfig struct { Thumb RequestFileData Duration int Length int - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config VideoNoteConfig) params() (Params, error) { @@ -932,7 +932,7 @@ func (config VideoNoteConfig) params() (Params, error) { params.AddNonZero("duration", config.Duration) params.AddNonZero("length", config.Length) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1020,7 +1020,7 @@ type VoiceConfig struct { ParseMode string CaptionEntities []MessageEntity Duration int - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config VoiceConfig) params() (Params, error) { @@ -1032,7 +1032,7 @@ func (config VoiceConfig) params() (Params, error) { params.AddNonZero("duration", config.Duration) params.AddNonEmpty("caption", config.Caption) params.AddNonEmpty("parse_mode", config.ParseMode) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } err = params.AddInterface("caption_entities", config.CaptionEntities) @@ -1060,7 +1060,7 @@ type LocationConfig struct { LivePeriod int // optional Heading int // optional ProximityAlertRadius int // optional - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config LocationConfig) params() (Params, error) { @@ -1072,7 +1072,7 @@ func (config LocationConfig) params() (Params, error) { params.AddNonZero("live_period", config.LivePeriod) params.AddNonZero("heading", config.Heading) params.AddNonZero("proximity_alert_radius", config.ProximityAlertRadius) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1135,7 +1135,7 @@ type VenueConfig struct { FoursquareType string GooglePlaceID string GooglePlaceType string - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config VenueConfig) params() (Params, error) { @@ -1149,7 +1149,7 @@ func (config VenueConfig) params() (Params, error) { params.AddNonEmpty("foursquare_type", config.FoursquareType) params.AddNonEmpty("google_place_id", config.GooglePlaceID) params.AddNonEmpty("google_place_type", config.GooglePlaceType) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1167,7 +1167,7 @@ type ContactConfig struct { FirstName string LastName string VCard string - EphemeralMessageParameters *EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config ContactConfig) params() (Params, error) { @@ -1178,7 +1178,7 @@ func (config ContactConfig) params() (Params, error) { params.AddNonEmpty("last_name", config.LastName) params.AddNonEmpty("vcard", config.VCard) - if err = params.AddInterface("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { + if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } diff --git a/docs/examples/bot-api-10.md b/docs/examples/bot-api-10.md index 49b74a2a..c2eb6e56 100644 --- a/docs/examples/bot-api-10.md +++ b/docs/examples/bot-api-10.md @@ -58,7 +58,7 @@ An incoming ephemeral command has `Message.MessageID == 0` and a separate `Messa ```go incoming := update.Message reply := tgbotapi.NewMessage(incoming.Chat.ID, "Checking...") -reply.EphemeralMessageParameters = &tgbotapi.EphemeralMessageParameters{ +reply.EphemeralMessageParameters = tgbotapi.EphemeralMessageParameters{ ReceiverUserID: incoming.From.ID, } reply.ReplyParameters.EphemeralMessageID = incoming.EphemeralMessageID diff --git a/examples/bot_api_10.go b/examples/bot_api_10.go index b115bf0d..c6885301 100644 --- a/examples/bot_api_10.go +++ b/examples/bot_api_10.go @@ -138,7 +138,7 @@ func handle_bot_api_10_2_update(bot *api.BotAPI, update api.Update) { } reply := api.NewMessage(update.Message.Chat.ID, "Checking...") - reply.EphemeralMessageParameters = &api.EphemeralMessageParameters{ + reply.EphemeralMessageParameters = api.EphemeralMessageParameters{ ReceiverUserID: update.Message.From.ID, } reply.ReplyParameters.EphemeralMessageID = update.Message.EphemeralMessageID diff --git a/helper_methods.go b/helper_methods.go index a88ccb25..09f31f18 100644 --- a/helper_methods.go +++ b/helper_methods.go @@ -928,7 +928,7 @@ func newBaseEphemeralMessage(chatID, receiverUserID int64, ephemeralMessageID in // ReplyParameters.EphemeralMessageID within 15 seconds of an eligible action. func NewEphemeralMessage(chatID, receiverUserID int64, text string) MessageConfig { config := NewMessage(chatID, text) - config.EphemeralMessageParameters = &EphemeralMessageParameters{ + config.EphemeralMessageParameters = EphemeralMessageParameters{ ReceiverUserID: receiverUserID, } return config diff --git a/types.go b/types.go index e06b1e3e..7f57ee14 100644 --- a/types.go +++ b/types.go @@ -2796,7 +2796,9 @@ type RichTextBotCommand struct { // RichTextButton describes a rich text button. type RichTextButton struct { - Type string `json:"type"` + // Type type of the rich text, always "button" + Type string `json:"type"` + // Button the button Button RichMessageButton `json:"button"` } @@ -2914,8 +2916,13 @@ type RichBlockBlockQuotation struct { // RichBlockExpandableBlockQuotation describes an expandable block quotation. type RichBlockExpandableBlockQuotation struct { - Type string `json:"type"` - Text RichText `json:"text"` + // Type type of the block, always "expandable_blockquote" + Type string `json:"type"` + // Text content of the block + Text RichText `json:"text"` + // Credit credit of the block + // + // optional Credit RichText `json:"credit,omitempty"` } @@ -2945,8 +2952,14 @@ type RichBlockTable struct { Type string `json:"type"` Cells [][]RichBlockTableCell `json:"cells"` IsBordered bool `json:"is_bordered,omitempty"` - IsStriped bool `json:"is_striped,omitempty"` - IsCompact bool `json:"is_compact,omitempty"` + // IsStriped True, if the table is striped + // + // optional + IsStriped bool `json:"is_striped,omitempty"` + // IsCompact True, if table cells have smaller indents + // + // optional + IsCompact bool `json:"is_compact,omitempty"` Caption RichText `json:"caption,omitempty"` } @@ -2968,13 +2981,36 @@ type RichBlockMap struct { Caption *RichBlockCaption `json:"caption,omitempty"` } +// RichBlockButtonsAlignLeft is the left horizontal alignment for RichBlockButtons. +const RichBlockButtonsAlignLeft = "left" + +// RichBlockButtonsAlignCenter is the center horizontal alignment for RichBlockButtons. +const RichBlockButtonsAlignCenter = "center" + +// RichBlockButtonsAlignRight is the right horizontal alignment for RichBlockButtons. +const RichBlockButtonsAlignRight = "right" + // RichBlockButtons describes a row of rich message buttons. type RichBlockButtons struct { - Type string `json:"type"` + // Type type of the block, always "buttons" + Type string `json:"type"` + // Buttons the buttons Buttons []RichMessageButton `json:"buttons"` - Align string `json:"align,omitempty"` + // Align horizontal alignment of the buttons; must be one of "left", "center", or "right" + // + // optional + Align string `json:"align,omitempty"` } +// Left returns true if the buttons are aligned to the left. +func (b RichBlockButtons) Left() bool { return b.Align == RichBlockButtonsAlignLeft } + +// Center returns true if the buttons are centered. +func (b RichBlockButtons) Center() bool { return b.Align == RichBlockButtonsAlignCenter } + +// Right returns true if the buttons are aligned to the right. +func (b RichBlockButtons) Right() bool { return b.Align == RichBlockButtonsAlignRight } + // RichBlockAnimation describes an animation block. type RichBlockAnimation struct { Type string `json:"type"` @@ -2992,9 +3028,14 @@ type RichBlockAudio struct { // RichBlockDocument describes a document block. type RichBlockDocument struct { - Type string `json:"type"` - Document Document `json:"document"` - Caption *RichBlockCaption `json:"caption,omitempty"` + // Type type of the block, always "document" + Type string `json:"type"` + // Document the document + Document Document `json:"document"` + // Caption caption of the block + // + // optional + Caption *RichBlockCaption `json:"caption,omitempty"` } // RichBlockPhoto describes a photo block. @@ -3096,8 +3137,13 @@ type InputRichBlockBlockQuotation struct { // InputRichBlockExpandableBlockQuotation describes an outgoing expandable block quotation. type InputRichBlockExpandableBlockQuotation struct { - Type string `json:"type"` - Text RichText `json:"text"` + // Type type of the block, always "expandable_blockquote" + Type string `json:"type"` + // Text content of the block + Text RichText `json:"text"` + // Credit credit of the block + // + // optional Credit RichText `json:"credit,omitempty"` } @@ -3127,8 +3173,14 @@ type InputRichBlockTable struct { Type string `json:"type"` Cells [][]RichBlockTableCell `json:"cells"` IsBordered bool `json:"is_bordered,omitempty"` - IsStriped bool `json:"is_striped,omitempty"` - IsCompact bool `json:"is_compact,omitempty"` + // IsStriped True, if the table is striped + // + // optional + IsStriped bool `json:"is_striped,omitempty"` + // IsCompact True, if table cells have smaller indents + // + // optional + IsCompact bool `json:"is_compact,omitempty"` Caption RichText `json:"caption,omitempty"` } @@ -3152,11 +3204,25 @@ type InputRichBlockMap struct { // InputRichBlockButtons describes an outgoing row of rich message buttons. type InputRichBlockButtons struct { - Type string `json:"type"` + // Type type of the block, always "buttons" + Type string `json:"type"` + // Buttons list of 1-8 buttons to send Buttons []RichMessageButton `json:"buttons"` - Align string `json:"align,omitempty"` + // Align horizontal alignment of the buttons; must be one of "left", "center", or "right" + // + // optional + Align string `json:"align,omitempty"` } +// Left returns true if the buttons are aligned to the left. +func (b InputRichBlockButtons) Left() bool { return b.Align == RichBlockButtonsAlignLeft } + +// Center returns true if the buttons are centered. +func (b InputRichBlockButtons) Center() bool { return b.Align == RichBlockButtonsAlignCenter } + +// Right returns true if the buttons are aligned to the right. +func (b InputRichBlockButtons) Right() bool { return b.Align == RichBlockButtonsAlignRight } + // InputRichBlockAnimation describes an outgoing animation block. type InputRichBlockAnimation struct { Type string `json:"type"` @@ -3173,9 +3239,14 @@ type InputRichBlockAudio struct { // InputRichBlockDocument describes an outgoing document block. type InputRichBlockDocument struct { - Type string `json:"type"` + // Type type of the block, always "document" + Type string `json:"type"` + // Document the document; caption on the document is ignored Document InputMediaDocument `json:"document"` - Caption *RichBlockCaption `json:"caption,omitempty"` + // Caption caption of the block + // + // optional + Caption *RichBlockCaption `json:"caption,omitempty"` } // InputRichBlockPhoto describes an outgoing photo block. @@ -3800,7 +3871,11 @@ type ChatAdministratorRights struct { CanManageTopics bool `json:"can_manage_topics"` CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` CanManageTags bool `json:"can_manage_tags,omitempty"` - CanSendWelcomeMessages bool `json:"can_send_welcome_messages,omitempty"` + // CanSendWelcomeMessages True, if the administrator can manage chat welcome + // messages or directly send them in the case of bots. + // + // optional + CanSendWelcomeMessages bool `json:"can_send_welcome_messages,omitempty"` } // ChatMember contains information about one member of a chat. @@ -7165,6 +7240,7 @@ type CommunityChatAdded struct { // CommunityChatJoined describes a chat being joined by a user from a community. type CommunityChatJoined struct { + // Community the community from which the chat was joined Community Community `json:"community"` } @@ -7173,36 +7249,109 @@ type CommunityChatRemoved struct{} // MessageGenerationStopped describes an update about a user stopping message generation. type MessageGenerationStopped struct { - Chat Chat `json:"chat"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DraftID int `json:"draft_id"` + // Chat chat in which the message is generated + Chat Chat `json:"chat"` + // MessageThreadID unique identifier of the message thread in which the message is generated + // + // optional + MessageThreadID int `json:"message_thread_id,omitempty"` + // DraftID unique identifier of the message draft which was stopped + DraftID int `json:"draft_id"` } // EphemeralMessageParameters describes parameters of an ephemeral message to send. type EphemeralMessageParameters struct { - ReceiverUserID int64 `json:"receiver_user_id"` - CallbackQueryID string `json:"callback_query_id,omitempty"` - ReplaceCallbackQueryMessage bool `json:"replace_callback_query_message,omitempty"` + // ReceiverUserID identifier of the user who will receive the message + ReceiverUserID int64 `json:"receiver_user_id"` + // CallbackQueryID identifier of the callback query which triggered the message, if any + // + // optional + CallbackQueryID string `json:"callback_query_id,omitempty"` + // ReplaceCallbackQueryMessage pass True to show the ephemeral message in place of + // the original message; must be False for callback queries from ephemeral messages + // + // optional + ReplaceCallbackQueryMessage bool `json:"replace_callback_query_message,omitempty"` } // DisabledButton represents a disabled button which does nothing. type DisabledButton struct{} +// RichMessageButtonStyleDanger is the red button style. +const RichMessageButtonStyleDanger = "danger" + +// RichMessageButtonStyleSuccess is the green button style. +const RichMessageButtonStyleSuccess = "success" + +// RichMessageButtonStylePrimary is the blue button style. +const RichMessageButtonStylePrimary = "primary" + +// RichMessageButtonStyleLink shows the button as a regular link without borders. +const RichMessageButtonStyleLink = "link" + // RichMessageButton represents a button in a RichMessage. +// +// Exactly one of the optional action fields must be used to specify the type of the button. type RichMessageButton struct { - Text RichText `json:"text"` - Style string `json:"style,omitempty"` - URL string `json:"url,omitempty"` - CallbackData string `json:"callback_data,omitempty"` - WebApp *WebAppInfo `json:"web_app,omitempty"` - LoginURL *LoginURL `json:"login_url,omitempty"` - SwitchInlineQuery *string `json:"switch_inline_query,omitempty"` - SwitchInlineQueryCurrentChat *string `json:"switch_inline_query_current_chat,omitempty"` - SwitchInlineQueryChosenChat *SwitchInlineQueryChosenChat `json:"switch_inline_query_chosen_chat,omitempty"` - CopyText *CopyTextButton `json:"copy_text,omitempty"` - Disabled *DisabledButton `json:"disabled,omitempty"` + // Text text of the button; may contain only plain text, RichTextCustomEmoji and RichTextDateTime entities + Text RichText `json:"text"` + // Style style of the button; must be one of "danger", "success", "primary", or "link" + // + // optional + Style string `json:"style,omitempty"` + // URL HTTP or tg:// URL to be opened when the button is pressed + // + // optional + URL string `json:"url,omitempty"` + // CallbackData data to be sent in a callback query to the bot when the button is pressed, 1-64 bytes + // + // optional + CallbackData string `json:"callback_data,omitempty"` + // WebApp description of the Web App that will be launched when the user presses the button + // + // optional + WebApp *WebAppInfo `json:"web_app,omitempty"` + // LoginURL an HTTPS URL used to automatically authorize the user; not supported for ephemeral messages + // + // optional + LoginURL *LoginURL `json:"login_url,omitempty"` + // SwitchInlineQuery if set, pressing the button will prompt the user to select one of their chats, + // open that chat and insert the bot's username and the specified inline query in the input field + // + // optional + SwitchInlineQuery *string `json:"switch_inline_query,omitempty"` + // SwitchInlineQueryCurrentChat if set, pressing the button will insert the bot's username and the + // specified inline query in the current chat's input field + // + // optional + SwitchInlineQueryCurrentChat *string `json:"switch_inline_query_current_chat,omitempty"` + // SwitchInlineQueryChosenChat if set, pressing the button will prompt the user to select one of their + // chats of the specified type and insert the bot's username and the specified inline query + // + // optional + SwitchInlineQueryChosenChat *SwitchInlineQueryChosenChat `json:"switch_inline_query_chosen_chat,omitempty"` + // CopyText a button that copies the specified text to the clipboard + // + // optional + CopyText *CopyTextButton `json:"copy_text,omitempty"` + // Disabled if set, then the button is disabled and does nothing + // + // optional + Disabled *DisabledButton `json:"disabled,omitempty"` } +// Danger returns true if the button uses the danger style. +func (b RichMessageButton) Danger() bool { return b.Style == RichMessageButtonStyleDanger } + +// Success returns true if the button uses the success style. +func (b RichMessageButton) Success() bool { return b.Style == RichMessageButtonStyleSuccess } + +// Primary returns true if the button uses the primary style. +func (b RichMessageButton) Primary() bool { return b.Style == RichMessageButtonStylePrimary } + +// LinkStyle returns true if the button uses the link style. +func (b RichMessageButton) LinkStyle() bool { return b.Style == RichMessageButtonStyleLink } + // PollOptionAdded describes a service message about an option added to a poll. type PollOptionAdded struct { PollMessage *MaybeInaccessibleMessage `json:"poll_message,omitempty"` @@ -7507,16 +7656,25 @@ type UniqueGift struct { // UniqueGiftInfo describes information about a unique gift in a message. type UniqueGiftInfo struct { - Gift UniqueGift `json:"gift"` - Origin string `json:"origin"` - Text string `json:"text,omitempty"` - Entities []MessageEntity `json:"entities,omitempty"` - IsPrivate bool `json:"is_private,omitempty"` - LastResaleCurrency string `json:"last_resale_currency,omitempty"` - LastResaleAmount int `json:"last_resale_amount,omitempty"` - OwnedGiftID string `json:"owned_gift_id,omitempty"` - TransferStarCount int `json:"transfer_star_count,omitempty"` - NextTransferDate int64 `json:"next_transfer_date,omitempty"` + Gift UniqueGift `json:"gift"` + Origin string `json:"origin"` + // Text text of the message that was added to the gift + // + // optional + Text string `json:"text,omitempty"` + // Entities special entities that appear in the text + // + // optional + Entities []MessageEntity `json:"entities,omitempty"` + // IsPrivate True, if the sender and gift text are shown only to the gift receiver + // + // optional + IsPrivate bool `json:"is_private,omitempty"` + LastResaleCurrency string `json:"last_resale_currency,omitempty"` + LastResaleAmount int `json:"last_resale_amount,omitempty"` + OwnedGiftID string `json:"owned_gift_id,omitempty"` + TransferStarCount int `json:"transfer_star_count,omitempty"` + NextTransferDate int64 `json:"next_transfer_date,omitempty"` } // UserProfileAudios contains audios displayed on user profile. From 6e23f38d955babfb64893c873eab443d24465827 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:00:56 +0200 Subject: [PATCH 3/5] Apply gofumpt to fix struct field alignment --- configs.go | 2 +- types.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/configs.go b/configs.go index ab16f025..607408c1 100644 --- a/configs.go +++ b/configs.go @@ -740,7 +740,7 @@ type DocumentConfig struct { ParseMode string CaptionEntities []MessageEntity DisableContentTypeDetection bool - EphemeralMessageParameters EphemeralMessageParameters + EphemeralMessageParameters EphemeralMessageParameters } func (config DocumentConfig) params() (Params, error) { diff --git a/types.go b/types.go index 7f57ee14..f83ee47e 100644 --- a/types.go +++ b/types.go @@ -2959,8 +2959,8 @@ type RichBlockTable struct { // IsCompact True, if table cells have smaller indents // // optional - IsCompact bool `json:"is_compact,omitempty"` - Caption RichText `json:"caption,omitempty"` + IsCompact bool `json:"is_compact,omitempty"` + Caption RichText `json:"caption,omitempty"` } // RichBlockDetails describes an expandable details block. @@ -3180,8 +3180,8 @@ type InputRichBlockTable struct { // IsCompact True, if table cells have smaller indents // // optional - IsCompact bool `json:"is_compact,omitempty"` - Caption RichText `json:"caption,omitempty"` + IsCompact bool `json:"is_compact,omitempty"` + Caption RichText `json:"caption,omitempty"` } // InputRichBlockDetails describes an outgoing details block. From f72abd90114f4fe741c6e6ffafb59329de61ed30 Mon Sep 17 00:00:00 2001 From: OvyFlash <46941696+OvyFlash@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:41:51 +0300 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> --- bot_api_10_2_test.go | 3 ++- bot_api_10_3_test.go | 15 ++++++++++++--- configs.go | 32 ++++++++++++++++++++++---------- docs/examples/bot-api-10.md | 2 +- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/bot_api_10_2_test.go b/bot_api_10_2_test.go index e8971759..0b5ec7d1 100644 --- a/bot_api_10_2_test.go +++ b/bot_api_10_2_test.go @@ -33,7 +33,8 @@ func TestBotAPI102InputRichBlockJSONContract(t *testing.T) { {name: "pull quotation", block: InputRichBlockPullQuotation{Type: "pullquote", Text: "quote", Credit: "author"}, typeName: "pullquote", fieldMatch: `"credit":"author"`}, {name: "collage", block: InputRichBlockCollage{Type: "collage", Blocks: []InputRichBlock{InputRichBlockPhoto{Type: "photo", Photo: photo}}}, typeName: "collage", fieldMatch: `"blocks":[`}, {name: "slideshow", block: InputRichBlockSlideshow{Type: "slideshow", Blocks: []InputRichBlock{InputRichBlockPhoto{Type: "photo", Photo: photo}}}, typeName: "slideshow", fieldMatch: `"blocks":[`}, - {name: "table", block: InputRichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Text: "cell", Align: "left", Valign: "middle"}}}, IsBordered: true, IsCompact: true}, typeName: "table", fieldMatch: `"is_compact":true`}, + {name: "table", block: InputRichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Text: "cell", Align: "left", Valign: "middle"}}}, IsBordered: true}, typeName: "table", fieldMatch: `"is_bordered":true`}, + {name: "compact table", block: InputRichBlockTable{Type: "table", Cells: [][]RichBlockTableCell{{{Text: "cell", Align: "left", Valign: "middle"}}}, IsBordered: true, IsCompact: true}, typeName: "table", fieldMatch: `"is_compact":true`}, {name: "details", block: InputRichBlockDetails{Type: "details", Summary: "summary", Blocks: []InputRichBlock{InputRichBlockParagraph{Type: "paragraph", Text: "body"}}, IsOpen: true}, typeName: "details", fieldMatch: `"is_open":true`}, {name: "map", block: InputRichBlockMap{Type: "map", Location: Location{Latitude: 10.5, Longitude: 20.25}, Zoom: 12, Width: 640, Height: 480}, typeName: "map", fieldMatch: `"zoom":12`}, {name: "buttons", block: InputRichBlockButtons{Type: "buttons", Buttons: []RichMessageButton{{Text: "Go", CallbackData: "go"}}, Align: "center"}, typeName: "buttons", fieldMatch: `"align":"center"`}, diff --git a/bot_api_10_3_test.go b/bot_api_10_3_test.go index 11b6a523..61db374e 100644 --- a/bot_api_10_3_test.go +++ b/bot_api_10_3_test.go @@ -144,7 +144,7 @@ func TestBotAPI103NewTypesJSON(t *testing.T) { Type: "document", Document: NewInputMediaDocument(FileID("doc")), }, - want: `"type":"document"`, + want: `"document":{"type":"document","media":"doc"}`, }, { name: "unique gift info", @@ -237,11 +237,20 @@ func TestBotAPI103UpdateStoppedMessageGeneration(t *testing.T) { func TestBotAPI103RichDocumentUpload(t *testing.T) { document := NewInputMediaDocument(FileBytes{Name: "notes.pdf", Bytes: []byte("%PDF")}) - config := NewSendRichMessage(1, NewInputRichMessageBlocks(InputRichBlockDocument{ + block := InputRichBlockDocument{ Type: "document", Document: document, - })) + } + config := NewSendRichMessage(1, NewInputRichMessageBlocks(block)) assertRichMessageUpload(t, config, []string{"rich-message-block-0"}) + + data, err := json.Marshal(block) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "attach://") { + t.Fatalf("user block was mutated: %s", data) + } } func stringPtr(v string) *string { diff --git a/configs.go b/configs.go index 607408c1..6eb8d08d 100644 --- a/configs.go +++ b/configs.go @@ -932,6 +932,9 @@ func (config VideoNoteConfig) params() (Params, error) { params.AddNonZero("duration", config.Duration) params.AddNonZero("length", config.Length) + if err != nil { + return params, err + } if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1072,6 +1075,9 @@ func (config LocationConfig) params() (Params, error) { params.AddNonZero("live_period", config.LivePeriod) params.AddNonZero("heading", config.Heading) params.AddNonZero("proximity_alert_radius", config.ProximityAlertRadius) + if err != nil { + return params, err + } if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1149,6 +1155,9 @@ func (config VenueConfig) params() (Params, error) { params.AddNonEmpty("foursquare_type", config.FoursquareType) params.AddNonEmpty("google_place_id", config.GooglePlaceID) params.AddNonEmpty("google_place_type", config.GooglePlaceType) + if err != nil { + return params, err + } if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1178,6 +1187,9 @@ func (config ContactConfig) params() (Params, error) { params.AddNonEmpty("last_name", config.LastName) params.AddNonEmpty("vcard", config.VCard) + if err != nil { + return params, err + } if err = params.AddInterfaceNonZero("ephemeral_message_parameters", config.EphemeralMessageParameters); err != nil { return params, err } @@ -1526,7 +1538,7 @@ type EditEphemeralMessageTextConfig struct { Text string ParseMode string Entities []MessageEntity - RichMessage *InputRichMessage + RichMessage InputRichMessage LinkPreviewOptions LinkPreviewOptions ReplyMarkup *InlineKeyboardMarkup } @@ -1542,11 +1554,8 @@ func (config EditEphemeralMessageTextConfig) params() (Params, error) { if err = params.AddInterface("entities", config.Entities); err != nil { return params, err } - if config.RichMessage != nil { - prepared := prepareInputRichMessageForParams(*config.RichMessage) - if err = params.AddInterface("rich_message", prepared); err != nil { - return params, err - } + if err = params.AddInterfaceNonZero("rich_message", prepareInputRichMessageForParams(config.RichMessage)); err != nil { + return params, err } if err = params.AddInterfaceNonZero("link_preview_options", config.LinkPreviewOptions); err != nil { return params, err @@ -1561,10 +1570,7 @@ func (EditEphemeralMessageTextConfig) method() string { } func (config EditEphemeralMessageTextConfig) files() []RequestFile { - if config.RichMessage == nil { - return nil - } - return prepareInputRichMessageForFiles(*config.RichMessage) + return prepareInputRichMessageForFiles(config.RichMessage) } // EditEphemeralMessageMediaConfig edits the media of an ephemeral message. @@ -1580,6 +1586,9 @@ func (config EditEphemeralMessageMediaConfig) params() (Params, error) { return params, err } + if isNilParamValue(config.Media) { + config.Media = nil + } preparedMedia := prepareInputMediaForParams([]InputMedia{config.Media}) if err = params.AddInterface("media", preparedMedia[0]); err != nil { return params, err @@ -1594,6 +1603,9 @@ func (EditEphemeralMessageMediaConfig) method() string { } func (config EditEphemeralMessageMediaConfig) files() []RequestFile { + if isNilParamValue(config.Media) { + return nil + } return prepareInputMediaForFiles([]InputMedia{config.Media}) } diff --git a/docs/examples/bot-api-10.md b/docs/examples/bot-api-10.md index c2eb6e56..87168fa6 100644 --- a/docs/examples/bot-api-10.md +++ b/docs/examples/bot-api-10.md @@ -39,7 +39,7 @@ richMessage.Media = []tgbotapi.InputRichMessageMedia{ _, err := bot.SendRichMessage(tgbotapi.NewSendRichMessage(chatID, richMessage)) ``` -Exactly one of `HTML`, `Markdown`, or `Blocks` must be used. Direct upload of new files is not available for `SendRichMessageDraftConfig` or inline `EditMessageTextConfig`; use a Telegram `file_id` or an HTTP URL there. +Exactly one of `HTML`, `Markdown`, or `Blocks` must be used. Direct upload of new files is not available for `SendRichMessageDraftConfig` or inline `EditMessageTextConfig`; use a Telegram `file_id` there. ## Ephemeral Commands and Messages From 029037a4386d8c473e5bc78877f8187474961944 Mon Sep 17 00:00:00 2001 From: Oleh Date: Wed, 26 Aug 2026 22:44:03 +0300 Subject: [PATCH 5/5] Fix Bot API 10.3 test --- bot_api_10_3_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot_api_10_3_test.go b/bot_api_10_3_test.go index 61db374e..08d88fe8 100644 --- a/bot_api_10_3_test.go +++ b/bot_api_10_3_test.go @@ -57,7 +57,7 @@ func TestBotAPI103DraftStopParams(t *testing.T) { func TestBotAPI103EditEphemeralExtensions(t *testing.T) { rich := NewInputRichMessageHTML("

hi

") text := NewEditEphemeralMessageText(1, 2, 3, "") - text.RichMessage = &rich + text.RichMessage = rich params, err := text.params() if err != nil { t.Fatalf("params: %v", err)