Skip to content

Commit 9a75f63

Browse files
amtiYoclaude
andcommitted
Add ephemeral media and the ephemeral media, caption and button edits
framy's preview card and its Model and Settings panels should reach only the person who asked in a group, and they are photos. Bot API 10.3 sends media ephemerally through ephemeral_message_parameters, and edits it with editEphemeralMessageMedia, editEphemeralMessageCaption and editEphemeralMessageReplyMarkup; this package had the text and rich ones only. CaptionOptions takes an Ephemeral and ReplyToEphemeral, so every media send can be ephemeral, uploads included. The three edits are probe-safe: Telegram's server asks for the media or the receiver before it acts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8aa585e commit 9a75f63

6 files changed

Lines changed: 303 additions & 37 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ See [`docs/versioning.md`](docs/versioning.md) for what the numbers mean and
1313

1414
Use this section for changes that are merged but not released yet.
1515

16+
### Added
17+
18+
- **Ephemeral media.** `CaptionOptions` gained `Ephemeral`, an `Ephemeral`
19+
with the receiver, the button press that authorizes the send and
20+
`ReplaceCallbackQueryMessage`, and `ReplyToEphemeral`, the incoming
21+
ephemeral message being answered. `SendPhoto`, `SendAnimation`,
22+
`SendVideo`, `SendAudio` and `SendDocument` take them, uploads included. A
23+
bot that is not an admin of the chat needs the press or the ephemeral
24+
message, within 15 seconds; an admin needs neither.
25+
- **`EditEphemeralMessageMedia`, `EditEphemeralMessageCaption` and
26+
`EditEphemeralMessageReplyMarkup`.** The media edit uploads a new file as
27+
`EditMessageMedia` does. Telegram answers these with true, not a message,
28+
so the file_id of an upload is not reported. "Message is not modified" is
29+
success, as for the other edits.
30+
- The three edits are in `probeSafe`: on an empty body Telegram's server
31+
asks for the media or the receiver before it does anything, so Preflight
32+
can require them.
33+
1634
## v0.2.0-alpha.1 - 2026-10-27
1735

1836
### Added

‎capability.go‎

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,35 +37,38 @@ import (
3737
// deleteMessages "Message identifiers are not specified", and
3838
// setMessageReaction and deleteMessageReaction "chat_id is empty".
3939
var probeSafe = map[string]bool{
40-
"answerCallbackQuery": true,
41-
"answerInlineQuery": true,
42-
"copyMessage": true,
43-
"deleteEphemeralMessage": true,
44-
"deleteMessage": true,
45-
"deleteMessageReaction": true,
46-
"deleteMessages": true,
47-
"editEphemeralMessageText": true,
48-
"editMessageCaption": true,
49-
"editMessageMedia": true,
50-
"editMessageReplyMarkup": true,
51-
"editMessageText": true,
52-
"forwardMessage": true,
53-
"getChat": true,
54-
"getChatMember": true,
55-
"getFile": true,
56-
"sendAnimation": true,
57-
"sendAudio": true,
58-
"sendChatAction": true,
59-
"sendDocument": true,
60-
"sendMediaGroup": true,
61-
"sendMessage": true,
62-
"sendMessageDraft": true,
63-
"sendPhoto": true,
64-
"sendRichMessage": true,
65-
"sendRichMessageDraft": true,
66-
"sendVideo": true,
67-
"sendVoice": true,
68-
"setMessageReaction": true,
40+
"answerCallbackQuery": true,
41+
"answerInlineQuery": true,
42+
"copyMessage": true,
43+
"deleteEphemeralMessage": true,
44+
"deleteMessage": true,
45+
"deleteMessageReaction": true,
46+
"deleteMessages": true,
47+
"editEphemeralMessageCaption": true,
48+
"editEphemeralMessageMedia": true,
49+
"editEphemeralMessageReplyMarkup": true,
50+
"editEphemeralMessageText": true,
51+
"editMessageCaption": true,
52+
"editMessageMedia": true,
53+
"editMessageReplyMarkup": true,
54+
"editMessageText": true,
55+
"forwardMessage": true,
56+
"getChat": true,
57+
"getChatMember": true,
58+
"getFile": true,
59+
"sendAnimation": true,
60+
"sendAudio": true,
61+
"sendChatAction": true,
62+
"sendDocument": true,
63+
"sendMediaGroup": true,
64+
"sendMessage": true,
65+
"sendMessageDraft": true,
66+
"sendPhoto": true,
67+
"sendRichMessage": true,
68+
"sendRichMessageDraft": true,
69+
"sendVideo": true,
70+
"sendVoice": true,
71+
"setMessageReaction": true,
6972
}
7073

7174
// MissingMethodsError reports methods the server does not implement. It is

‎capability_test.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,12 @@ func TestMethodsThisPackageSendsAreProbeSafe(t *testing.T) {
398398
// anything: sendAnimation without a file ("There is no animation in the
399399
// request"), editMessageCaption without a message ("Message identifier is not
400400
// specified"), deleteMessages without ids ("Message identifiers are not
401-
// specified"), the reactions without a chat ("chat_id is empty").
401+
// specified"), the reactions without a chat ("chat_id is empty"), the
402+
// ephemeral edits without media ("Parameter \"media\" is required") or a
403+
// receiver ("Invalid receiver_user_id specified").
402404
var framyProbeMethods = []string{
403-
"deleteMessageReaction", "deleteMessages", "editMessageCaption", "sendAnimation", "setMessageReaction",
405+
"deleteMessageReaction", "deleteMessages", "editEphemeralMessageCaption", "editEphemeralMessageMedia",
406+
"editEphemeralMessageReplyMarkup", "editMessageCaption", "sendAnimation", "setMessageReaction",
404407
}
405408

406409
func TestPreflightCanRequireTheMethodsAddedForFramy(t *testing.T) {

‎ephemeral.go‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// SPDX-License-Identifier: Apache-2.0
22
package tg
33

4-
import "context"
4+
import (
5+
"context"
6+
"fmt"
7+
)
58

69
// Ephemeral messages (Bot API 10.3) are visible to one person in a group. Two
710
// details decide whether a call succeeds:
@@ -13,6 +16,34 @@ import "context"
1316
// of a tapped button. After that window a fresh ephemeral message is
1417
// refused, so long work must edit a placeholder posted immediately.
1518

19+
// Ephemeral addresses a send to one member of a group: SendPhoto,
20+
// SendAnimation, SendVideo, SendAudio and SendDocument take it in their
21+
// options. A bot that is an administrator of the chat may send one at any
22+
// time. Any other bot needs what authorizes it, within 15 seconds: the press
23+
// in CallbackQueryID, or the incoming ephemeral message in
24+
// CaptionOptions.ReplyToEphemeral.
25+
type Ephemeral struct {
26+
ReceiverUserID int64
27+
// CallbackQueryID is the button press the message answers.
28+
CallbackQueryID string
29+
// ReplaceCallbackQueryMessage shows the message to the receiver in place
30+
// of the one whose button was pressed; the others keep seeing that one. It
31+
// must be false for a press on an ephemeral message, which is edited with
32+
// the EditEphemeral methods instead.
33+
ReplaceCallbackQueryMessage bool
34+
}
35+
36+
func (e *Ephemeral) fields() map[string]any {
37+
params := map[string]any{"receiver_user_id": e.ReceiverUserID}
38+
if e.CallbackQueryID != "" {
39+
params["callback_query_id"] = e.CallbackQueryID
40+
}
41+
if e.ReplaceCallbackQueryMessage {
42+
params["replace_callback_query_message"] = true
43+
}
44+
return params
45+
}
46+
1647
// SendEphemeralMessage answers a message so that only receiverUserID sees it.
1748
func (c *Client) SendEphemeralMessage(ctx context.Context, chatID, receiverUserID, ephemeralMessageID int64, text string, markup *InlineKeyboardMarkup) (Message, error) {
1849
req := map[string]any{
@@ -72,6 +103,53 @@ func (c *Client) EditEphemeralRichHTML(ctx context.Context, chatID, receiverUser
72103
}
73104

74105
func (c *Client) editEphemeral(ctx context.Context, chatID, receiverUserID, ephemeralMessageID int64, content map[string]any, markup *InlineKeyboardMarkup) error {
106+
return c.editEphemeralWith(ctx, "editEphemeralMessageText", chatID, receiverUserID, ephemeralMessageID, content, nil, markup)
107+
}
108+
109+
// EditEphemeralMessageMedia replaces the file of an ephemeral message, as
110+
// [Client.EditMessageMedia] does for a message in the chat: a file_id, a URL,
111+
// a path on a --local server or an upload, with the caption and its options
112+
// on the InputMedia. Telegram returns no message, so the file_id of an upload
113+
// is not reported. A "message is not modified" answer is success.
114+
func (c *Client) EditEphemeralMessageMedia(ctx context.Context, chatID, receiverUserID, ephemeralMessageID int64, media InputMedia, markup *InlineKeyboardMarkup) error {
115+
if media == nil {
116+
return fmt.Errorf("editEphemeralMessageMedia requires media")
117+
}
118+
object, file := media.item()
119+
var files []attachment
120+
value, err := c.attachInner(&files, "media", file)
121+
if err != nil {
122+
return err
123+
}
124+
object["media"] = value
125+
return c.editEphemeralWith(ctx, "editEphemeralMessageMedia", chatID, receiverUserID, ephemeralMessageID, map[string]any{"media": object}, files, markup)
126+
}
127+
128+
// EditEphemeralMessageCaption replaces the caption of an ephemeral message.
129+
// The caption is HTML and an empty one removes it, as in
130+
// [Client.EditMessageCaption]; opts.Markup is the keyboard after the edit.
131+
// A "message is not modified" answer is success.
132+
func (c *Client) EditEphemeralMessageCaption(ctx context.Context, chatID, receiverUserID, ephemeralMessageID int64, caption string, opts EditCaptionOptions) error {
133+
content := map[string]any{"caption": caption}
134+
if caption != "" {
135+
content["parse_mode"] = "HTML"
136+
}
137+
if opts.ShowCaptionAboveMedia {
138+
content["show_caption_above_media"] = true
139+
}
140+
return c.editEphemeralWith(ctx, "editEphemeralMessageCaption", chatID, receiverUserID, ephemeralMessageID, content, nil, opts.Markup)
141+
}
142+
143+
// EditEphemeralMessageReplyMarkup changes only the buttons of an ephemeral
144+
// message; nil takes them away. A "message is not modified" answer is
145+
// success.
146+
func (c *Client) EditEphemeralMessageReplyMarkup(ctx context.Context, chatID, receiverUserID, ephemeralMessageID int64, markup *InlineKeyboardMarkup) error {
147+
return c.editEphemeralWith(ctx, "editEphemeralMessageReplyMarkup", chatID, receiverUserID, ephemeralMessageID, nil, nil, markup)
148+
}
149+
150+
// editEphemeralWith runs one of the editEphemeralMessage methods. They address
151+
// the message by a flat receiver_user_id, unlike a send, and answer true.
152+
func (c *Client) editEphemeralWith(ctx context.Context, method string, chatID, receiverUserID, ephemeralMessageID int64, content map[string]any, files []attachment, markup *InlineKeyboardMarkup) error {
75153
req := map[string]any{
76154
"chat_id": chatID,
77155
"receiver_user_id": receiverUserID,
@@ -86,14 +164,14 @@ func (c *Client) editEphemeral(ctx context.Context, chatID, receiverUserID, ephe
86164
var resp struct {
87165
OK bool `json:"ok"`
88166
}
89-
if err := c.post(ctx, "editEphemeralMessageText", req, &resp); err != nil {
167+
if err := c.submit(ctx, method, req, files, &resp); err != nil {
90168
if IsMessageNotModified(err) {
91169
return nil
92170
}
93171
return err
94172
}
95173
if !resp.OK {
96-
return okFalse("editEphemeralMessageText")
174+
return okFalse(method)
97175
}
98176
return nil
99177
}

‎ephemeral_test.go‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
package tg
33

44
import (
5+
"bytes"
56
"context"
7+
"encoding/json"
8+
"net/http"
9+
"strings"
610
"testing"
711
)
812

@@ -106,3 +110,147 @@ func TestEphemeralTextSuppressesLinkPreviewsLikeEveryOtherSend(t *testing.T) {
106110
})
107111
}
108112
}
113+
114+
// A bot that is not an admin may answer a button press with an ephemeral
115+
// photo, shown to the one who pressed in place of the message they pressed
116+
// on.
117+
func TestSendPhotoCanBeEphemeral(t *testing.T) {
118+
handler, method, body := capture(t, `{"ok":true,"result":{"message_id":0,"ephemeral_message_id":5}}`)
119+
c, _ := newTestClient(t, handler)
120+
121+
msg, err := c.SendPhoto(context.Background(), 42, InputFileString{Data: "file-1"}, CaptionOptions{
122+
ReplyTo: 10,
123+
Caption: "only for you",
124+
Ephemeral: &Ephemeral{ReceiverUserID: 77, CallbackQueryID: "cb-1", ReplaceCallbackQueryMessage: true},
125+
})
126+
if err != nil {
127+
t.Fatalf("SendPhoto: %v", err)
128+
}
129+
if *method != "sendPhoto" || msg.EphemeralMessageID != 5 {
130+
t.Fatalf("method %q, message %+v", *method, msg)
131+
}
132+
params, _ := (*body)["ephemeral_message_parameters"].(map[string]any)
133+
if params["receiver_user_id"] != float64(77) || params["callback_query_id"] != "cb-1" || params["replace_callback_query_message"] != true {
134+
t.Fatalf("ephemeral_message_parameters = %v", params)
135+
}
136+
if _, flat := (*body)["receiver_user_id"]; flat {
137+
t.Fatal("receiver_user_id at the top level is the removed 10.2 shape")
138+
}
139+
// An ephemeral reply needs the message it answers to be there, so the
140+
// flag the public send carries is left out.
141+
reply, _ := (*body)["reply_parameters"].(map[string]any)
142+
if reply["message_id"] != float64(10) || len(reply) != 1 {
143+
t.Fatalf("reply_parameters = %v", reply)
144+
}
145+
}
146+
147+
// An ephemeral command authorizes the answer through the reply to it.
148+
func TestAnEphemeralReplyNamesTheEphemeralMessage(t *testing.T) {
149+
handler, _, body := capture(t, `{"ok":true,"result":{"message_id":0,"ephemeral_message_id":6}}`)
150+
c, _ := newTestClient(t, handler)
151+
152+
if _, err := c.SendAnimation(context.Background(), 42, InputFileString{Data: "loop"}, AnimationOptions{CaptionOptions: CaptionOptions{
153+
ReplyToEphemeral: 3,
154+
Ephemeral: &Ephemeral{ReceiverUserID: 77},
155+
}}); err != nil {
156+
t.Fatalf("SendAnimation: %v", err)
157+
}
158+
reply, _ := (*body)["reply_parameters"].(map[string]any)
159+
if reply["ephemeral_message_id"] != float64(3) || len(reply) != 1 {
160+
t.Fatalf("reply_parameters = %v", reply)
161+
}
162+
params, _ := (*body)["ephemeral_message_parameters"].(map[string]any)
163+
if len(params) != 1 || params["receiver_user_id"] != float64(77) {
164+
t.Fatalf("ephemeral_message_parameters = %v, want the receiver alone", params)
165+
}
166+
}
167+
168+
func TestAnUploadedEphemeralPhotoCarriesItsParametersAsJSON(t *testing.T) {
169+
handler, got := captureUpload(t, `{"ok":true,"result":{"message_id":0,"ephemeral_message_id":7}}`)
170+
c, _ := newTestClient(t, handler)
171+
172+
if _, err := c.SendPhoto(context.Background(), 42, InputFileUpload{Filename: "card.png", Data: bytes.NewReader([]byte("png"))}, CaptionOptions{
173+
Ephemeral: &Ephemeral{ReceiverUserID: 77},
174+
}); err != nil {
175+
t.Fatalf("SendPhoto: %v", err)
176+
}
177+
if got.files["photo"] != "png" || got.fields["ephemeral_message_parameters"] != `{"receiver_user_id":77}` {
178+
t.Fatalf("fields %v, files %v", got.fields, got.files)
179+
}
180+
}
181+
182+
// The edit uploads a new picture the way editMessageMedia does, and addresses
183+
// the message by the flat receiver.
184+
func TestEditEphemeralMessageMediaUploadsTheNewPhoto(t *testing.T) {
185+
handler, got := captureUpload(t, `{"ok":true,"result":true}`)
186+
c, _ := newTestClient(t, handler)
187+
188+
markup := &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{{{Text: "Generate", CallbackData: "go"}}}}
189+
err := c.EditEphemeralMessageMedia(context.Background(), 42, 77, 5, InputMediaPhoto{
190+
Media: InputFileUpload{Filename: "card2.png", Data: bytes.NewReader([]byte("png2"))},
191+
Caption: "<b>new</b>",
192+
}, markup)
193+
if err != nil {
194+
t.Fatalf("EditEphemeralMessageMedia: %v", err)
195+
}
196+
if got.method != "editEphemeralMessageMedia" {
197+
t.Fatalf("method = %q", got.method)
198+
}
199+
if got.fields["chat_id"] != "42" || got.fields["receiver_user_id"] != "77" || got.fields["ephemeral_message_id"] != "5" {
200+
t.Fatalf("fields = %v", got.fields)
201+
}
202+
var media map[string]any
203+
if err := json.Unmarshal([]byte(got.fields["media"]), &media); err != nil {
204+
t.Fatalf("media = %q: %v", got.fields["media"], err)
205+
}
206+
attach, _ := media["media"].(string)
207+
if media["type"] != "photo" || media["caption"] != "<b>new</b>" || !strings.HasPrefix(attach, "attach://") {
208+
t.Fatalf("media = %v", media)
209+
}
210+
if got.files[strings.TrimPrefix(attach, "attach://")] != "png2" {
211+
t.Fatalf("files = %v", got.files)
212+
}
213+
if !strings.Contains(got.fields["reply_markup"], `"callback_data":"go"`) {
214+
t.Fatalf("reply_markup = %q", got.fields["reply_markup"])
215+
}
216+
}
217+
218+
func TestEditEphemeralMessageCaptionAndButtons(t *testing.T) {
219+
handler, method, body := capture(t, `{"ok":true,"result":true}`)
220+
c, _ := newTestClient(t, handler)
221+
222+
if err := c.EditEphemeralMessageCaption(context.Background(), 42, 77, 5, "≈ $0.04", EditCaptionOptions{ShowCaptionAboveMedia: true}); err != nil {
223+
t.Fatalf("EditEphemeralMessageCaption: %v", err)
224+
}
225+
if *method != "editEphemeralMessageCaption" || (*body)["caption"] != "≈ $0.04" || (*body)["parse_mode"] != "HTML" ||
226+
(*body)["show_caption_above_media"] != true || (*body)["receiver_user_id"] != float64(77) || (*body)["ephemeral_message_id"] != float64(5) {
227+
t.Fatalf("%s %v", *method, *body)
228+
}
229+
if err := c.EditEphemeralMessageReplyMarkup(context.Background(), 42, 77, 5, nil); err != nil {
230+
t.Fatalf("EditEphemeralMessageReplyMarkup: %v", err)
231+
}
232+
if _, has := (*body)["reply_markup"]; *method != "editEphemeralMessageReplyMarkup" || has {
233+
t.Fatalf("%s %v", *method, *body)
234+
}
235+
}
236+
237+
// A press that changes nothing is not a failure, as for every other edit.
238+
func TestEphemeralEditsTreatNotModifiedAsSuccess(t *testing.T) {
239+
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
240+
w.WriteHeader(http.StatusBadRequest)
241+
_, _ = w.Write([]byte(`{"ok":false,"description":"Bad Request: message is not modified"}`))
242+
})
243+
ctx := context.Background()
244+
if err := c.EditEphemeralMessageMedia(ctx, 1, 2, 3, InputMediaPhoto{Media: InputFileString{Data: "id"}}, nil); err != nil {
245+
t.Fatalf("media: %v", err)
246+
}
247+
if err := c.EditEphemeralMessageCaption(ctx, 1, 2, 3, "x", EditCaptionOptions{}); err != nil {
248+
t.Fatalf("caption: %v", err)
249+
}
250+
if err := c.EditEphemeralMessageReplyMarkup(ctx, 1, 2, 3, nil); err != nil {
251+
t.Fatalf("markup: %v", err)
252+
}
253+
if err := c.EditEphemeralMessageMedia(ctx, 1, 2, 3, nil, nil); err == nil {
254+
t.Fatal("an edit without media was sent")
255+
}
256+
}

0 commit comments

Comments
 (0)