feat: sync ephemeral transient messages

Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages).

Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
A 2026-07-20 16:43:27 +08:00
parent 3f78eaa2c6
commit f49c817def
53 changed files with 5793 additions and 112 deletions

View file

@ -15,6 +15,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"go.uber.org/zap"
@ -23,6 +24,8 @@ import (
type BotsService interface {
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error)
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error)
GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error)
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error)
@ -49,6 +52,12 @@ type GatewayService interface {
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
}
type EphemeralGatewayService interface {
BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error)
BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error)
BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error)
}
type GatewayUpdateWaiter interface {
BotAPIUpdateWaitVersion(botID int64) uint64
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
@ -186,18 +195,54 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
switch strings.ToLower(method) {
case "getme":
h.getMe(w, r, botID)
case "setmycommands":
h.setMyCommands(w, r, botID)
case "deletemycommands":
h.deleteMyCommands(w, r, botID)
case "getmycommands":
h.getMyCommands(w, r, botID)
case "getupdates":
h.getUpdates(w, r, botID)
case "sendmessage":
h.sendMessage(w, r, botID)
case "sendphoto":
h.sendMedia(w, r, botID, "photo")
case "sendanimation":
h.sendMedia(w, r, botID, "animation")
case "sendaudio":
h.sendMedia(w, r, botID, "audio")
case "senddocument":
h.sendMedia(w, r, botID, "document")
case "sendlivephoto":
h.sendMedia(w, r, botID, "live_photo")
case "sendsticker":
h.sendMedia(w, r, botID, "sticker")
case "sendvideo":
h.sendMedia(w, r, botID, "video")
case "sendvideonote":
h.sendMedia(w, r, botID, "video_note")
case "sendvoice":
h.sendMedia(w, r, botID, "voice")
case "sendcontact":
h.sendEphemeralContact(w, r, botID)
case "sendlocation":
h.sendEphemeralLocation(w, r, botID, false)
case "sendvenue":
h.sendEphemeralLocation(w, r, botID, true)
case "editmessagetext":
h.editMessageText(w, r, botID)
case "deletemessage":
h.deleteMessage(w, r, botID)
case "editephemeralmessagetext":
h.editEphemeralMessage(w, r, botID, "text")
case "editephemeralmessagemedia":
h.editEphemeralMessage(w, r, botID, "media")
case "editephemeralmessagecaption":
h.editEphemeralMessage(w, r, botID, "caption")
case "editephemeralmessagereplymarkup":
h.editEphemeralMessage(w, r, botID, "reply_markup")
case "deleteephemeralmessage":
h.deleteEphemeralMessage(w, r, botID)
case "answercallbackquery":
h.answerCallbackQuery(w, r, botID)
case "getfile":
@ -481,6 +526,10 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
return
}
text := values["text"]
if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
@ -498,6 +547,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
return
}
}
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if isEphemeral {
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
TopMessageID: ephemeral.topMessageID, Kind: "message", Text: text, Entities: entities, ReplyMarkup: markup,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
return
}
replyTo := apiInt(values["reply_to_message_id"], 0)
msg, err := h.gateway.BotAPISendMessage(r.Context(), botID, chatID, text, entities, markup, apiBool(values["disable_web_page_preview"]), apiBool(values["disable_notification"]), replyTo)
if err != nil {
@ -535,6 +611,10 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
@ -543,11 +623,56 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
return
}
}
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind)
var file, secondary domain.BotAPIFileInput
var ok bool
if kind == "live_photo" {
file, ok = botAPIFileInput(values["photo"], files, "photo", values)
if ok {
secondary, ok = botAPIFileInput(values["live_photo"], files, "live_photo", values)
}
} else {
file, ok = botAPIFileInput(values[kind], files, kind, values)
}
if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
// The official Bot API does not accept HTTP URLs for the video part of a
// live photo or for video notes. Reject them before either the ordinary or
// ephemeral send path can fetch the remote resource.
if (kind == "live_photo" && secondary.RemoteURL != "") || (kind == "video_note" && file.RemoteURL != "") {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if isEphemeral {
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities,
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
return
}
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
@ -1252,6 +1377,9 @@ func apiErrorDescription(err error) string {
"QUERY_ID_INVALID",
"MESSAGE_ID_INVALID",
"MESSAGE_NOT_MODIFIED",
"BOT_COMMAND_INVALID",
"EPHEMERAL_MESSAGE_ID_INVALID",
"EPHEMERAL_ACTION_EXPIRED",
"CHAT_WRITE_FORBIDDEN",
"CHAT_ADMIN_REQUIRED",
"REPLY_MESSAGE_ID_INVALID",