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