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

@ -287,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
return res.SenderMessage, nil
}
func (r *Router) BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) {
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 {
return domain.EphemeralMessage{}, errors.New("BOT_INVALID")
}
peer, ok := botAPIPeerFromChatID(input.ChatID)
if !ok || peer.Type != domain.PeerTypeChannel {
return domain.EphemeralMessage{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(input.ReplyMarkup); err != nil {
return domain.EphemeralMessage{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, input.ReplyMarkup); err != nil {
return domain.EphemeralMessage{}, err
}
baseContent := domain.EphemeralContent{
Message: input.Text, Entities: append([]domain.MessageEntity(nil), input.Entities...), ReplyMarkup: input.ReplyMarkup,
}
if !utf8.ValidString(baseContent.Message) || utf8.RuneCountInString(baseContent.Message) > domain.MaxMessageTextLength || len(baseContent.Entities) > domain.MaxMessageEntityCount ||
!validEphemeralEntityBounds(baseContent.Message, baseContent.Entities) {
return domain.EphemeralMessage{}, errors.New("ENTITY_BOUNDS_INVALID")
}
message, _, err := r.deps.Ephemeral.SendFromBotLazy(ctx, domain.SendBotEphemeralRequest{
BotUserID: input.BotUserID, ReceiverUserID: input.ReceiverUserID, Peer: peer,
TopMessageID: input.TopMessageID, ReplyToEphemeralID: input.ReplyToEphemeralID,
ActionMessageID: input.ReplyToEphemeralID, CallbackQueryID: input.CallbackQueryID,
}, func(buildCtx context.Context) (domain.EphemeralContent, error) {
content := baseContent
if input.DirectMedia != nil {
content.Media = input.DirectMedia
if content.Media.Geo != nil && content.Media.Geo.AccessHash == 0 {
content.Media.Geo.AccessHash, _ = randomGeoAccessHash()
}
if content.Media.Venue != nil && content.Media.Venue.Geo.AccessHash == 0 {
content.Media.Venue.Geo.AccessHash, _ = randomGeoAccessHash()
}
} else if input.Kind != "message" {
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.Kind, input.File, input.SecondaryFile)
if err != nil {
return domain.EphemeralContent{}, err
}
content.Media = media
}
return content, nil
})
if err != nil {
return domain.EphemeralMessage{}, ephemeralBotAPIError(err)
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
return message, nil
}
func (r *Router) BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) {
if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 || input.MessageID <= 0 {
return false, errors.New("MESSAGE_ID_INVALID")
}
peer, ok := botAPIPeerFromChatID(input.ChatID)
if !ok || peer.Type != domain.PeerTypeChannel {
return false, errors.New("CHAT_ID_INVALID")
}
fields := input.Fields
if fields.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(fields.ReplyMarkup); err != nil {
return false, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, fields.ReplyMarkup); err != nil {
return false, err
}
}
if fields.SetMessage && (!utf8.ValidString(fields.Message) || !validEphemeralEntityBounds(fields.Message, fields.Entities) || utf8.RuneCountInString(fields.Message) > domain.MaxMessageTextLength) {
return false, errors.New("ENTITY_BOUNDS_INVALID")
}
message, err := r.deps.Ephemeral.EditFieldsFromBotLazy(ctx, input.BotUserID, input.ReceiverUserID, peer, input.MessageID, input.Mode, func(buildCtx context.Context) (domain.EditEphemeralFields, error) {
built := fields
if input.MediaKind != "" {
media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.MediaKind, input.File, input.SecondaryFile)
if err != nil {
return domain.EditEphemeralFields{}, err
}
built.SetMedia = true
built.Media = media
}
return built, nil
})
if err != nil {
return false, ephemeralBotAPIError(err)
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushEdit, TargetUserID: message.ReceiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
return true, nil
}
func (r *Router) BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) {
peer, ok := botAPIPeerFromChatID(chatID)
if r == nil || r.deps.Ephemeral == nil || !ok || peer.Type != domain.PeerTypeChannel {
return false, errors.New("CHAT_ID_INVALID")
}
message, deleted, err := r.deps.Ephemeral.Delete(ctx, botUserID, receiverUserID, peer, messageID)
if err != nil {
return false, ephemeralBotAPIError(err)
}
if deleted {
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: receiverUserID,
TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message,
})
}
return true, nil
}
func ephemeralBotAPIError(err error) error {
switch {
case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), errors.Is(err, domain.ErrEphemeralDeleted):
return errors.New("EPHEMERAL_MESSAGE_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralReplyExpired):
return errors.New("EPHEMERAL_ACTION_EXPIRED")
case errors.Is(err, domain.ErrEphemeralPeerInvalid):
return errors.New("CHAT_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralReceiverInvalid):
return errors.New("USER_ID_INVALID")
case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch):
return errors.New("CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrEphemeralVersionConflict):
return errors.New("MESSAGE_NOT_MODIFIED")
default:
return err
}
}
func (r *Router) botAPIEphemeralMedia(ctx context.Context, botID int64, kind string, file, secondary domain.BotAPIFileInput) (*domain.MessageMedia, error) {
if kind == "live_photo" {
photo, err := r.botAPIMedia(ctx, botID, "photo", file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
if err != nil {
return nil, err
}
video, err := r.botAPIDocumentMedia(ctx, botID, "video", secondary)
if err != nil {
return nil, err
}
photo.LivePhotoVideo = video.Document
return photo, nil
}
if kind == "photo" {
return r.botAPIMedia(ctx, botID, kind, file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes)
}
return r.botAPIDocumentMedia(ctx, botID, kind, file)
}
func (r *Router) botAPIDocumentMedia(ctx context.Context, botID int64, kind string, file domain.BotAPIFileInput) (*domain.MessageMedia, error) {
if r.deps.Files == nil {
return nil, errors.New("MEDIA_INVALID")
}
attrs, forceFile, ok := botAPIDocumentKindAttributes(kind, file)
if !ok {
return nil, errors.New("MEDIA_INVALID")
}
var document domain.Document
var err error
switch {
case len(file.Bytes) > 0:
document, err = r.deps.Files.CreateDocumentFromBytes(ctx, file.Bytes, domain.DocumentSpec{MimeType: file.MimeType, Attributes: attrs, ForceFile: forceFile})
case file.RemoteURL != "":
document, err = r.deps.Files.CreateDocumentFromURL(ctx, file.RemoteURL)
document.Attributes = mergeDocumentAttributes(document.Attributes, attrs)
case file.LocationKey != "":
id, valid := botAPIDocumentID(file.LocationKey)
if !valid {
return nil, errors.New("FILE_ID_INVALID")
}
var found bool
document, found, err = r.deps.Files.GetDocument(ctx, id)
if err == nil && !found {
err = errors.New("FILE_ID_INVALID")
}
default:
err = errors.New("FILE_ID_INVALID")
}
if err != nil {
return nil, botAPIMediaErr(err)
}
if !botAPIDocumentMatchesKind(document, kind) {
return nil, errors.New("MEDIA_INVALID")
}
return messageMediaFromDocument(document, false, 0), nil
}
func botAPIDocumentKindAttributes(kind string, file domain.BotAPIFileInput) ([]domain.DocumentAttribute, bool, bool) {
filename := botAPIDocumentAttributes(file.FileName)
w, h, duration := file.Width, file.Height, file.Duration
if w <= 0 {
w = 1
}
if h <= 0 {
h = 1
}
if duration <= 0 {
duration = 1
}
switch kind {
case "document":
return filename, true, true
case "animation":
return append(filename,
domain.DocumentAttribute{Kind: domain.DocAttrAnimated},
domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), NoSound: true}), false, true
case "audio":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Title: file.Title, Performer: file.Performer}), false, true
case "sticker":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrSticker, W: w, H: h, Alt: file.Emoji}), false, true
case "video":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), SupportsStreaming: true}), false, true
case "video_note":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), RoundMessage: true, SupportsStreaming: true}), false, true
case "voice":
return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Voice: true}), false, true
default:
return nil, false, false
}
}
func mergeDocumentAttributes(base, additional []domain.DocumentAttribute) []domain.DocumentAttribute {
out := append([]domain.DocumentAttribute(nil), base...)
seen := make(map[domain.DocumentAttributeKind]struct{}, len(base)+len(additional))
for _, attribute := range base {
seen[attribute.Kind] = struct{}{}
}
for _, attribute := range additional {
if _, exists := seen[attribute.Kind]; exists {
continue
}
seen[attribute.Kind] = struct{}{}
out = append(out, attribute)
}
return out
}
func botAPIDocumentMatchesKind(document domain.Document, kind string) bool {
has := func(target domain.DocumentAttributeKind, predicate func(domain.DocumentAttribute) bool) bool {
for _, attribute := range document.Attributes {
if attribute.Kind == target && (predicate == nil || predicate(attribute)) {
return true
}
}
return false
}
switch kind {
case "document":
return document.ID > 0
case "animation":
return has(domain.DocAttrAnimated, nil)
case "audio":
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return !a.Voice })
case "sticker":
return document.IsSticker()
case "video":
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return !a.RoundMessage })
case "video_note":
return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return a.RoundMessage })
case "voice":
return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return a.Voice })
default:
return false
}
}
func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
switch {
case chatID > 0: