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:

View file

@ -79,6 +79,9 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
continue
}
if item.Ephemeral != nil {
continue
}
if item.Callback != nil && item.Callback.InlineMessage != nil {
continue
}
@ -171,6 +174,9 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time
if !ok {
return "", false
}
if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) {
return "", false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery {
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
return "", false
@ -205,11 +211,40 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time
return eventType, true
}
func botAPIQueuedEphemeralValid(botID int64, item domain.BotAPIUpdate, now time.Time) bool {
if item.Ephemeral == nil {
return true
}
message := item.Ephemeral.Message
if item.Ephemeral.Validate() != nil || item.SourcePts != 0 || item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID <= 0 ||
message.ID != item.MessageID || message.Peer != item.Peer || message.Expired(now) ||
message.SenderUserID <= 0 || message.ReceiverUserID <= 0 {
return false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery {
return message.SenderUserID == botID
}
return message.ReceiverUserID == botID
}
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
if !ok {
return domain.UpdateEvent{}, false
}
if item.Ephemeral != nil {
message := item.Ephemeral.EphemeralMessage()
event := domain.UpdateEvent{
UserID: botID, Type: eventType, Date: item.Date, Peer: item.Peer,
BotAPIUpdateID: item.ID, EphemeralMessage: &message,
}
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
event.BotCallbackQuery = &callback
}
return event, true
}
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
@ -220,6 +255,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
BotCallbackQuery: &callback,
}, true
@ -238,6 +274,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: item.Peer,
Message: msg,
@ -249,13 +286,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
}
msg.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: msg.Peer,
Message: msg,
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: msg.Peer,
Message: msg,
}, true
case domain.PeerTypeChannel:
msg, found := channelMessages[item.Peer.ID][item.MessageID]
@ -271,6 +309,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: item.Peer,
Message: projected,
@ -282,13 +321,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
}
projected.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: projected.Peer,
Message: projected,
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date,
Peer: projected.Peer,
Message: projected,
}, true
default:
return domain.UpdateEvent{}, false

View file

@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool {
func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
out := make([]domain.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description})
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
}
return out
}
@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
func tgBotCommands(in []domain.BotCommand) []tg.BotCommand {
out := make([]tg.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description})
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral})
}
return out
}

View file

@ -111,6 +111,10 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
Date: int(r.clock.Now().Unix()),
})
return r.waitBotCallbackAnswer(ctx, botUserID, queryID, pending)
}
func (r *Router) waitBotCallbackAnswer(ctx context.Context, botUserID, queryID int64, pending *pendingCallback) (*tg.MessagesBotCallbackAnswer, error) {
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel()
ticker := time.NewTicker(250 * time.Millisecond)

View file

@ -27,6 +27,10 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
if m.TTLSeconds > 0 {
out.TTLSeconds = m.TTLSeconds
}
if m.LivePhotoVideo != nil {
out.LivePhoto = true
out.SetVideo(tgDocument(*m.LivePhotoVideo))
}
return out
case domain.MessageMediaKindDocument:
nopremium := m.Nopremium

View file

@ -180,6 +180,15 @@ type AuthKeyTargetedSessionBinder interface {
PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// ExactLayerTransientSessionBinder is the admission boundary for updates whose
// constructors do not exist in older profiles. Implementations must filter the
// live session index before encoding, skip unknown/not-ready profiles, and must
// never queue the transient payload for later delivery.
type ExactLayerTransientSessionBinder interface {
PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout.
type OnlineUserProvider interface {
IsUserOnline(userID int64) bool
@ -805,6 +814,22 @@ type AIComposeService interface {
Compose(ctx context.Context, req domain.AIComposeRequest) (domain.AIComposeResult, error)
}
// EphemeralService owns Layer 228 short-lived bot/member state. It must never
// write ordinary messages, dialogs, pts/qts/seq logs or durable update outbox.
type EphemeralService interface {
SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error)
SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error)
SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error)
EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error)
EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error)
EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error)
Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error)
DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error)
Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error)
PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error)
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。
type Deps struct {
Auth AuthService
@ -817,6 +842,9 @@ type Deps struct {
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Users UsersService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore

460
internal/rpc/ephemeral.go Normal file
View file

@ -0,0 +1,460 @@
package rpc
import (
"context"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func (r *Router) registerEphemeral(d *tlprofile.Dispatcher) {
registerRPC[*tg.EphemeralSendMessageRequest](d, tlprofile.SemanticMethodEphemeralSendMessage, func(ctx context.Context, request *tg.EphemeralSendMessageRequest) (any, error) {
return r.onEphemeralSendMessage(ctx, request)
})
registerRPC[*tg.EphemeralDeleteMessageRequest](d, tlprofile.SemanticMethodEphemeralDeleteMessage, func(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (any, error) {
return r.onEphemeralDeleteMessage(ctx, request)
})
registerRPC[*tg.EphemeralReportMessageRequest](d, tlprofile.SemanticMethodEphemeralReportMessage, func(ctx context.Context, request *tg.EphemeralReportMessageRequest) (any, error) {
return r.onEphemeralReportMessage(ctx, request)
})
registerRPC[*tg.EphemeralGetCallbackAnswerRequest](d, tlprofile.SemanticMethodEphemeralGetCallbackAnswer, func(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (any, error) {
return r.onEphemeralGetCallbackAnswer(ctx, request)
})
}
func (r *Router) onEphemeralSendMessage(ctx context.Context, request *tg.EphemeralSendMessageRequest) (tg.UpdatesClass, error) {
if request == nil || r.deps.Ephemeral == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
return nil, peerIDInvalidErr()
}
receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID)
if err != nil {
return nil, internalErr()
}
if !found || !receiver.Bot {
return nil, userBotInvalidErr()
}
content, err := r.domainEphemeralInputContent(ctx, userID, request)
if err != nil {
return nil, err
}
topMessageID, replyID, err := ephemeralReplyFromInput(request.ReplyTo)
if err != nil {
return nil, err
}
queryID, _ := request.GetQueryID()
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return nil, internalErr()
}
message, fresh, err := r.deps.Ephemeral.SendFromClient(ctx, domain.SendClientEphemeralRequest{
SenderUserID: userID, ReceiverBotID: receiver.ID, Peer: peer,
QueryID: queryID, RandomID: request.RandomID, TopMessageID: topMessageID,
ReplyToEphemeralID: replyID, Content: content,
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID},
})
if err != nil {
return nil, ephemeralRPCError(err)
}
if fresh && r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: receiver.ID,
Kind: domain.BotAPIUpdateMessage,
Peer: message.Peer,
MessageID: message.ID,
Date: message.Date,
Ephemeral: domain.NewBotAPIEphemeralPayload(message),
}); err != nil {
r.log.Warn("enqueue bot api ephemeral message", zap.Int64("bot_user_id", receiver.ID), zap.Int("ephemeral_message_id", message.ID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(receiver.ID)
}
}
if fresh {
// OriginDevice belongs to the human sender and must not constrain the
// receiving bot's sessions.
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, Message: message,
})
}
// A lost create response can be retried after the ephemeral message was
// deleted. The random-id index deliberately returns its tombstone; reflect
// that final fact instead of projecting an impossible empty new message.
if message.Deleted {
return ephemeralDeleteUpdates(message, int(r.clock.Now().Unix())), nil
}
return r.ephemeralMessageUpdates(ctx, userID, message, false)
}
func (r *Router) onEphemeralGetCallbackAnswer(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel {
return nil, peerIDInvalidErr()
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return nil, err
}
data, _ := request.GetData()
callback, err := r.deps.Ephemeral.Callback(ctx, userID, device, peer, request.ID, data)
if err != nil {
return nil, ephemeralRPCError(err)
}
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), callback.BotUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Error(err))
return nil, internalErr()
}
defer r.callbacks.deregisterContext(context.Background(), callback.BotUserID, queryID)
created, err := r.deps.Ephemeral.PutCallbackAction(ctx, domain.EphemeralCallbackAction{
QueryID: queryID, BotUserID: callback.BotUserID, UserID: userID, Peer: peer,
MessageID: request.ID, TopMessageID: callback.Message.TopMessageID, Device: callback.Device, CreatedAt: callback.OccurredAt,
ExpiresAt: callback.OccurredAt.Add(domain.EphemeralReplyWindow),
})
if err != nil || !created {
return nil, internalErr()
}
botCallback := domain.BotCallbackQuery{
ID: queryID, BotUserID: callback.BotUserID, UserID: userID,
Peer: peer, MessageID: request.ID, ChatInstance: chatInstanceForPeer(callback.BotUserID, peer),
Data: append([]byte(nil), data...),
}
if r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: callback.BotUserID,
Kind: domain.BotAPIUpdateCallbackQuery,
Peer: peer,
MessageID: request.ID,
Date: int(callback.OccurredAt.Unix()),
Callback: &botCallback,
Ephemeral: domain.NewBotAPIEphemeralPayload(callback.Message),
}); err != nil {
r.log.Warn("enqueue bot api ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Int64("query_id", queryID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(callback.BotUserID)
}
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushCallback, TargetUserID: callback.BotUserID,
Message: callback.Message, Callback: &botCallback, Date: int(callback.OccurredAt.Unix()),
})
return r.waitBotCallbackAnswer(ctx, callback.BotUserID, queryID, pending)
}
func (r *Router) onEphemeralDeleteMessage(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (bool, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return false, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return false, err
}
receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID)
if err != nil {
return false, internalErr()
}
if !found {
return false, userIDInvalidErr()
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return false, err
}
message, deleted, err := r.deps.Ephemeral.DeleteFromDevice(ctx, userID, receiver.ID, device, peer, request.ID)
if err != nil {
return false, ephemeralRPCError(err)
}
if deleted {
for _, targetUserID := range []int64{message.SenderUserID, message.ReceiverUserID} {
var targetAuthKey [8]byte
if message.OriginDevice.UserID == targetUserID {
targetAuthKey = message.OriginDevice.BusinessAuthKeyID
}
r.publishEphemeralPush(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: targetUserID,
TargetBusinessAuthKey: targetAuthKey, Message: message,
})
}
}
return true, nil
}
func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.EphemeralReportMessageRequest) (tg.ReportResultClass, error) {
if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer)
if err != nil {
return nil, err
}
device, err := ephemeralDeviceFromContext(ctx, userID)
if err != nil {
return nil, err
}
target, err := r.deps.Ephemeral.ReportTarget(ctx, userID, device, peer, request.ID)
if err != nil {
return nil, ephemeralRPCError(err)
}
if utf8.RuneCountInString(request.Message) > 1024 {
return nil, messageTooLongErr()
}
result, err := reportResultForOption(string(request.Option))
if err != nil {
return nil, err
}
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
if r.deps.EphemeralReports == nil {
return nil, internalErr()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
return nil, internalErr()
}
return result, nil
}
func (r *Router) domainEphemeralInputContent(ctx context.Context, userID int64, request *tg.EphemeralSendMessageRequest) (domain.EphemeralContent, error) {
if !utf8.ValidString(request.Message) || utf8.RuneCountInString(request.Message) > domain.MaxMessageTextLength || len(request.Entities) > domain.MaxMessageEntityCount {
return domain.EphemeralContent{}, messageTooLongErr()
}
entities := domainMessageEntitiesForViewer(userID, request.Entities)
if len(entities) != len(request.Entities) || !validEphemeralEntityBounds(request.Message, entities) {
return domain.EphemeralContent{}, tgerr.New(400, "ENTITY_BOUNDS_INVALID")
}
var media *domain.MessageMedia
if request.Media != nil {
resolved, err := r.resolveInputMedia(ctx, userID, request.Media)
if err != nil {
return domain.EphemeralContent{}, err
}
if !ephemeralMediaAllowed(resolved) {
return domain.EphemeralContent{}, mediaTypeInvalidErr()
}
media = resolved
}
var markup *domain.MessageReplyMarkup
if request.ReplyMarkup != nil {
var err error
markup, err = domainReplyMarkupForSender(request.ReplyMarkup, false)
if err != nil {
return domain.EphemeralContent{}, replyMarkupErr(err)
}
}
// Layer 228 exposes f_rich_message on the request but its
// ephemeralMessage result has no field capable of carrying that content.
// Official TDesktop always sends an empty InputRichMessage here. Reject the
// otherwise lossy shape instead of acknowledging content the receiver could
// never reconstruct.
if request.RichMessage != nil {
return domain.EphemeralContent{}, inputConstructorInvalidErr()
}
if request.Message == "" && media == nil {
return domain.EphemeralContent{}, messageEmptyErr()
}
content := domain.EphemeralContent{Message: request.Message, Entities: entities, Media: media, ReplyMarkup: markup}
if domain.ValidateEphemeralContent(content) != nil {
return domain.EphemeralContent{}, inputRequestInvalidErr()
}
return content, nil
}
func ephemeralReplyFromInput(reply tg.InputReplyToClass) (topMessageID, ephemeralID int, err error) {
switch value := reply.(type) {
case nil:
return 0, 0, nil
case *tg.InputReplyToEphemeralMessage:
if value.ID <= 0 || value.ID > domain.MaxMessageBoxID {
return 0, 0, messageIDInvalidErr()
}
return 0, value.ID, nil
case *tg.InputReplyToMessage:
topMessageID = value.ReplyToMsgID
if explicit, ok := value.GetTopMsgID(); ok {
topMessageID = explicit
}
if topMessageID <= 0 || topMessageID > domain.MaxMessageBoxID {
return 0, 0, messageIDInvalidErr()
}
if value.ReplyToPeerID != nil || value.QuoteText != "" || len(value.QuoteEntities) != 0 || value.QuoteOffset != 0 ||
value.MonoforumPeerID != nil || value.TodoItemID != 0 || len(value.PollOption) != 0 {
return 0, 0, inputConstructorInvalidErr()
}
return topMessageID, 0, nil
default:
return 0, 0, inputConstructorInvalidErr()
}
}
func validEphemeralEntityBounds(message string, entities []domain.MessageEntity) bool {
utf16Length := 0
for _, runeValue := range message {
utf16Length++
if runeValue > 0xffff {
utf16Length++
}
}
for _, entity := range entities {
if entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || entity.Length > utf16Length-entity.Offset {
return false
}
}
return true
}
func ephemeralMediaAllowed(media *domain.MessageMedia) bool {
if media == nil || media.IsZero() {
return false
}
switch media.Kind {
case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument, domain.MessageMediaKindContact,
domain.MessageMediaKindGeo, domain.MessageMediaKindVenue:
return true
default:
return false
}
}
func (r *Router) ephemeralMessageUpdates(ctx context.Context, viewerUserID int64, message domain.EphemeralMessage, edited bool) (*tg.Updates, error) {
if r.deps.Users == nil || r.deps.Channels == nil {
return nil, internalErr()
}
users, err := r.deps.Users.ByIDs(ctx, viewerUserID, []int64{message.SenderUserID, message.ReceiverUserID})
if err != nil {
return nil, internalErr()
}
view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, message.Peer.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
wire := tgEphemeralMessage(viewerUserID, message)
var update tg.UpdateClass = &tg.UpdateNewEphemeralMessage{Message: wire}
if edited {
update = &tg.UpdateEditEphemeralMessage{Message: wire}
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Users: tgUsersForViewer(viewerUserID, users),
Chats: []tg.ChatClass{tgChannelChatForView(viewerUserID, view)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}, nil
}
func ephemeralDeleteUpdates(message domain.EphemeralMessage, date int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateDeleteEphemeralMessages{
Peer: tgPeer(message.Peer), IDs: []int{message.ID},
}},
Date: date,
Seq: 0,
}
}
func tgEphemeralMessage(viewerUserID int64, message domain.EphemeralMessage) tg.EphemeralMessage {
out := tg.EphemeralMessage{
Out: viewerUserID == message.SenderUserID,
ID: message.ID,
FromID: &tg.PeerUser{UserID: message.SenderUserID},
PeerID: tgPeer(message.Peer),
ReceiverID: message.ReceiverUserID,
Date: message.Date,
Message: message.Content.Message,
}
if message.TopMessageID > 0 {
out.SetTopMsgID(message.TopMessageID)
}
if len(message.Content.Entities) != 0 {
out.SetEntities(tgMessageEntities(message.Content.Entities))
}
if message.Content.Media != nil && !message.Content.Media.IsZero() {
out.SetMedia(tgMessageMedia(message.Content.Media))
}
if message.Content.ReplyMarkup != nil && !message.Content.ReplyMarkup.IsZero() {
out.SetReplyMarkup(tgReplyMarkup(message.Content.ReplyMarkup))
}
if message.ReplyToEphemeralID > 0 {
reply := &tg.MessageReplyHeader{ReplyToEphemeral: true}
reply.SetReplyToMsgID(message.ReplyToEphemeralID)
if message.TopMessageID > 0 {
reply.ForumTopic = true
reply.SetReplyToTopID(message.TopMessageID)
}
out.SetReplyTo(reply)
}
return out
}
func ephemeralDeviceFromContext(ctx context.Context, userID int64) (domain.EphemeralDevice, error) {
authKeyID, authOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return domain.EphemeralDevice{}, internalErr()
}
return domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, nil
}
func ephemeralRPCError(err error) error {
switch {
case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired),
errors.Is(err, domain.ErrEphemeralDeleted), errors.Is(err, domain.ErrEphemeralReplyExpired):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralPeerInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralSenderInvalid), errors.Is(err, domain.ErrEphemeralReceiverInvalid):
return userIDInvalidErr()
case errors.Is(err, domain.ErrEphemeralCommandInvalid):
return tgerr.New(400, "BOT_COMMAND_INVALID")
case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch):
return tgerr.New(403, "CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrEphemeralCallbackInvalid):
return dataInvalidErr()
case errors.Is(err, domain.ErrEphemeralInvalid), errors.Is(err, domain.ErrEphemeralRandomIDConflict),
errors.Is(err, domain.ErrEphemeralVersionConflict):
return inputRequestInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,107 @@
package rpc
import (
"context"
"time"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/store"
)
const ephemeralPushSubscribeRetry = time.Second
func (r *Router) RunEphemeralPushSubscriber(ctx context.Context) {
if r == nil || r.deps.EphemeralPush == nil {
return
}
for {
err := r.deps.EphemeralPush.SubscribeEphemeralPushes(ctx, func(ctx context.Context, event store.EphemeralPush) {
if event.SourceID == "" || event.SourceID == r.instanceID {
return
}
r.deliverEphemeralPushLocal(ctx, event)
})
if ctx.Err() != nil {
return
}
if err != nil {
r.log.Warn("ephemeral push subscriber stopped", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-time.After(ephemeralPushSubscribeRetry):
}
}
}
func (r *Router) publishEphemeralPush(ctx context.Context, event store.EphemeralPush) {
if r == nil || event.TargetUserID <= 0 {
return
}
event.SourceID = r.instanceID
if event.Date <= 0 {
event.Date = int(r.clock.Now().Unix())
}
r.deliverEphemeralPushLocal(ctx, event)
if r.deps.EphemeralPush != nil {
if err := r.deps.EphemeralPush.PublishEphemeralPush(ctx, event); err != nil {
r.log.Debug("publish ephemeral push", zap.String("kind", string(event.Kind)), zap.Int64("target_user_id", event.TargetUserID), zap.Error(err))
}
}
}
func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.EphemeralPush) {
if r == nil || r.deps.Sessions == nil || event.TargetUserID <= 0 || event.Message.ID <= 0 {
return
}
if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(event.TargetUserID) {
return
}
binder, ok := r.deps.Sessions.(ExactLayerTransientSessionBinder)
if !ok {
return
}
var updates tg.UpdatesClass
switch event.Kind {
case store.EphemeralPushNew, store.EphemeralPushEdit:
if event.TargetUserID != event.Message.ReceiverUserID || event.Message.Deleted {
return
}
built, err := r.ephemeralMessageUpdates(ctx, event.TargetUserID, event.Message, event.Kind == store.EphemeralPushEdit)
if err != nil {
return
}
updates = built
case store.EphemeralPushDelete:
if !event.Message.Deleted || (event.TargetUserID != event.Message.SenderUserID && event.TargetUserID != event.Message.ReceiverUserID) {
return
}
updates = ephemeralDeleteUpdates(event.Message, event.Date)
case store.EphemeralPushCallback:
callback := event.Callback
if callback == nil || callback.BotUserID != event.TargetUserID || callback.MessageID != event.Message.ID || callback.Peer != event.Message.Peer {
return
}
update := &tg.UpdateBotCallbackQuery{
QueryID: callback.ID, UserID: callback.UserID, Peer: tgPeer(callback.Peer),
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
}
update.SetData(callback.Data)
updates = &tg.Updates{Updates: []tg.UpdateClass{update}, Date: event.Date}
default:
return
}
minLayer := 228
if event.Kind == store.EphemeralPushCallback {
minLayer = 225
}
if event.TargetBusinessAuthKey != ([8]byte{}) {
_, _ = binder.PushToUserAuthKeyTransientAtLeastLayer(ctx, event.TargetUserID, event.TargetBusinessAuthKey, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
return
}
_, _ = binder.PushToUserTransientAtLeastLayer(ctx, event.TargetUserID, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
}

View file

@ -0,0 +1,220 @@
package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type ephemeralPushChannels struct {
ChannelsService
view domain.ChannelView
calls int
}
func (s *ephemeralPushChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
s.calls++
return s.view, nil
}
type ephemeralPushSessions struct {
SessionBinder
OnlineUserProvider
mu sync.Mutex
online bool
broadcasts []ephemeralPushCapture
targeted []ephemeralPushCapture
}
type ephemeralPushCapture struct {
userID int64
authKey [8]byte
minLayer int
message tg.UpdatesClass
}
func (s *ephemeralPushSessions) IsUserOnline(int64) bool { return s.online }
func (s *ephemeralPushSessions) PushToUserTransientAtLeastLayer(_ context.Context, userID int64, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, minLayer: minLayer, message: message})
return 1, nil
}
func (s *ephemeralPushSessions) PushToUserAuthKeyTransientAtLeastLayer(_ context.Context, userID int64, authKey [8]byte, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, minLayer: minLayer, message: message})
return 1, nil
}
func (s *ephemeralPushSessions) counts() (int, int) {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.broadcasts), len(s.targeted)
}
type inMemoryEphemeralBroker struct {
mu sync.Mutex
subscribers []func(context.Context, store.EphemeralPush)
registered chan struct{}
published []store.EphemeralPush
}
func newInMemoryEphemeralBroker() *inMemoryEphemeralBroker {
return &inMemoryEphemeralBroker{registered: make(chan struct{}, 8)}
}
func (b *inMemoryEphemeralBroker) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error {
b.mu.Lock()
b.published = append(b.published, event)
handlers := append([]func(context.Context, store.EphemeralPush){}, b.subscribers...)
b.mu.Unlock()
for _, handler := range handlers {
handler(ctx, event)
}
return nil
}
func (b *inMemoryEphemeralBroker) SubscribeEphemeralPushes(ctx context.Context, handler func(context.Context, store.EphemeralPush)) error {
b.mu.Lock()
b.subscribers = append(b.subscribers, handler)
b.mu.Unlock()
b.registered <- struct{}{}
<-ctx.Done()
return ctx.Err()
}
func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker := newInMemoryEphemeralBroker()
users := mapUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Bot", Bot: true},
2001: {ID: 2001, FirstName: "Alice"},
}}
view := domain.ChannelView{
Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true},
Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive},
}
channels1, channels2 := &ephemeralPushChannels{view: view}, &ephemeralPushChannels{view: view}
sessions1, sessions2 := &ephemeralPushSessions{online: true}, &ephemeralPushSessions{online: true}
r1 := New(Config{InstanceID: "one"}, Deps{Users: users, Channels: channels1, Sessions: sessions1, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System)
r2 := New(Config{InstanceID: "two"}, Deps{Users: users, Channels: channels2, Sessions: sessions2, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System)
go r1.RunEphemeralPushSubscriber(ctx)
go r2.RunEphemeralPushSubscriber(ctx)
for range 2 {
select {
case <-broker.registered:
case <-time.After(time.Second):
t.Fatal("subscriber did not register")
}
}
now := time.Now()
message := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
r1.publishEphemeralPush(ctx, store.EphemeralPush{Kind: store.EphemeralPushNew, TargetUserID: 2001, Message: message})
if broadcast, targeted := sessions1.counts(); broadcast != 1 || targeted != 0 {
t.Fatalf("source delivery broadcast=%d targeted=%d", broadcast, targeted)
}
if broadcast, targeted := sessions2.counts(); broadcast != 1 || targeted != 0 {
t.Fatalf("remote delivery broadcast=%d targeted=%d", broadcast, targeted)
}
if sessions1.broadcasts[0].minLayer != 228 || sessions2.broadcasts[0].minLayer != 228 {
t.Fatalf("min layers source=%d remote=%d", sessions1.broadcasts[0].minLayer, sessions2.broadcasts[0].minLayer)
}
if len(broker.published) != 1 || broker.published[0].SourceID != "one" {
t.Fatalf("published=%+v", broker.published)
}
key := [8]byte{9, 8, 7}
message.Deleted = true
message.Version++
message.Content = domain.EphemeralContent{}
r2.deliverEphemeralPushLocal(ctx, store.EphemeralPush{
Kind: store.EphemeralPushDelete, TargetUserID: 2001,
TargetBusinessAuthKey: key, Message: message, Date: int(time.Now().Unix()),
})
_, targeted := sessions2.counts()
if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].minLayer != 228 {
t.Fatalf("targeted=%+v", sessions2.targeted)
}
deletedUpdates, ok := sessions2.targeted[0].message.(*tg.Updates)
if !ok || deletedUpdates.Seq != 0 || len(deletedUpdates.Updates) != 1 {
t.Fatalf("delete updates=%#v", sessions2.targeted[0].message)
}
deleted, ok := deletedUpdates.Updates[0].(*tg.UpdateDeleteEphemeralMessages)
if !ok || len(deleted.IDs) != 1 || deleted.IDs[0] != message.ID {
t.Fatalf("delete update=%#v", deletedUpdates.Updates[0])
}
}
func TestEphemeralMessageUpdatesAreTransientAndPtsFree(t *testing.T) {
now := time.Now()
router := New(Config{}, Deps{
Users: mapUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Bot", Bot: true},
2001: {ID: 2001, FirstName: "Alice"},
}},
Channels: &ephemeralPushChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true},
Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive},
}},
}, zaptest.NewLogger(t), clock.System)
message := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
updates, err := router.ephemeralMessageUpdates(context.Background(), 2001, message, false)
if err != nil || updates.Seq != 0 || len(updates.Updates) != 1 {
t.Fatalf("updates=%#v err=%v", updates, err)
}
if _, ok := updates.Updates[0].(*tg.UpdateNewEphemeralMessage); !ok {
t.Fatalf("update type=%T", updates.Updates[0])
}
deleted := ephemeralDeleteUpdates(domain.EphemeralMessage{ID: message.ID, Peer: message.Peer}, int(now.Unix()))
if deleted.Seq != 0 {
t.Fatalf("delete seq=%d", deleted.Seq)
}
}
func TestEphemeralPushOfflineSkipsHydration(t *testing.T) {
channels := &ephemeralPushChannels{view: domain.ChannelView{Channel: domain.Channel{ID: 3001}}}
sessions := &ephemeralPushSessions{online: false}
now := time.Now()
router := New(Config{InstanceID: "offline"}, Deps{
Users: mapUsersService{users: map[int64]domain.User{}}, Channels: channels, Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
router.deliverEphemeralPushLocal(context.Background(), store.EphemeralPush{
Kind: store.EphemeralPushNew, TargetUserID: 2001,
Message: domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
},
})
if channels.calls != 0 {
t.Fatalf("offline push performed %d channel hydrations", channels.calls)
}
if broadcast, targeted := sessions.counts(); broadcast != 0 || targeted != 0 {
t.Fatalf("offline delivery broadcast=%d targeted=%d", broadcast, targeted)
}
}

View file

@ -0,0 +1,96 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type ephemeralReportChannels struct {
ChannelsService
view domain.ChannelView
}
func (s *ephemeralReportChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
return s.view, nil
}
type ephemeralReportService struct {
EphemeralService
target domain.EphemeralMessage
calls int
}
func (s *ephemeralReportService) ReportTarget(_ context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
s.calls++
if userID != s.target.ReceiverUserID || device.UserID != userID || device.BusinessAuthKeyID != s.target.OriginDevice.BusinessAuthKeyID ||
peer != s.target.Peer || id != s.target.ID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
return s.target, nil
}
func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
const userID int64 = 2001
const channelID int64 = 3001
now := time.Now()
authKey := [8]byte{1, 2, 3}
target := domain.EphemeralMessage{
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
SenderUserID: 1001, ReceiverUserID: userID, Date: int(now.Unix()), RandomID: 78,
Content: domain.EphemeralContent{Message: "abuse"},
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
reports := memory.NewEphemeralReportStore()
ephemeral := &ephemeralReportService{target: target}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
}}
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
}
result, err := router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultChooseOption); !ok || len(reports.Reports()) != 0 {
t.Fatalf("initial result=%T reports=%+v", result, reports.Reports())
}
request.Option = []byte("other")
result, err = router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultAddComment); !ok || len(reports.Reports()) != 0 {
t.Fatalf("comment result=%T reports=%+v", result, reports.Reports())
}
request.Option, request.Message = []byte("spam"), "evidence comment"
for range 2 {
result, err = router.onEphemeralReportMessage(ctx, request)
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.ReportResultReported); !ok {
t.Fatalf("final result=%T", result)
}
}
stored := reports.Reports()
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
t.Fatalf("reports=%+v", stored)
}
if ephemeral.calls != 4 {
t.Fatalf("ReportTarget calls=%d", ephemeral.calls)
}
}

View file

@ -274,6 +274,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerPremium(d)
r.registerAiCompose(d)
r.registerBots(d)
r.registerEphemeral(d)
r.dispatcher = d
return r

View file

@ -45,6 +45,12 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
if message := out[i].EphemeralMessage; message != nil {
collectEphemeralMessagePeerRefs(*message, userIDs, channelIDs)
if message.BotAPIReply != nil {
collectEphemeralMessagePeerRefs(*message.BotAPIReply, userIDs, channelIDs)
}
}
if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
}
@ -66,6 +72,24 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
return out
}
func collectEphemeralMessagePeerRefs(message domain.EphemeralMessage, userIDs, channelIDs map[int64]struct{}) {
if message.SenderUserID != 0 {
userIDs[message.SenderUserID] = struct{}{}
}
if message.ReceiverUserID != 0 {
userIDs[message.ReceiverUserID] = struct{}{}
}
addDomainPeerRef(message.Peer, 0, userIDs, channelIDs)
for _, entity := range message.Content.Entities {
if entity.UserID != 0 {
userIDs[entity.UserID] = struct{}{}
}
}
if message.Content.Media != nil && message.Content.Media.Contact != nil && message.Content.Media.Contact.UserID != 0 {
userIDs[message.Content.Media.Contact.UserID] = struct{}{}
}
}
type updateEventPeerRefs struct {
userIDs map[int64]struct{}
channelIDs map[int64]struct{}

View file

@ -629,7 +629,11 @@ func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) t
if len(profile.Commands) > 0 {
cmds := make([]tg.BotCommand, 0, len(profile.Commands))
for _, c := range profile.Commands {
cmds = append(cmds, tg.BotCommand{Command: c.Command, Description: c.Description})
cmds = append(cmds, tg.BotCommand{
Command: c.Command,
Description: c.Description,
Ephemeral: c.Ephemeral,
})
}
info.SetCommands(cmds)
}

View file

@ -0,0 +1,25 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
func TestTGBotInfoPreservesEphemeralCommandMarker(t *testing.T) {
got := tgBotInfoFromProfile(42, domain.BotProfile{
Commands: []domain.BotCommand{
{Command: "public", Description: "visible everywhere"},
{Command: "private", Description: "Layer 228 only", Ephemeral: true},
},
}, true)
if len(got.Commands) != 2 {
t.Fatalf("commands = %+v, want two", got.Commands)
}
if got.Commands[0].Ephemeral {
t.Fatalf("public command = %+v, want ephemeral=false", got.Commands[0])
}
if !got.Commands[1].Ephemeral {
t.Fatalf("private command = %+v, want ephemeral=true", got.Commands[1])
}
}