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:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
460
internal/rpc/ephemeral.go
Normal 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()
|
||||
}
|
||||
}
|
||||
107
internal/rpc/ephemeral_push.go
Normal file
107
internal/rpc/ephemeral_push.go
Normal 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)
|
||||
}
|
||||
220
internal/rpc/ephemeral_push_test.go
Normal file
220
internal/rpc/ephemeral_push_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
96
internal/rpc/ephemeral_rpc_test.go
Normal file
96
internal/rpc/ephemeral_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
25
internal/rpc/users_bot_info_test.go
Normal file
25
internal/rpc/users_bot_info_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue