owpengram-server/internal/rpc/ephemeral.go
A f49c817def 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.
2026-07-20 16:43:27 +08:00

460 lines
17 KiB
Go

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()
}
}