Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package domain
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
// EphemeralMessageRetention matches TDesktop's in-memory upper bound. The
|
|
// server never replays these records; the retention only keeps callback,
|
|
// edit, delete and abuse-report lookups coherent across instances.
|
|
EphemeralMessageRetention = 48 * time.Hour
|
|
// EphemeralReplyWindow is the official Bot API eligible-action window.
|
|
EphemeralReplyWindow = 15 * time.Second
|
|
// MaxEphemeralCreateAttempts bounds random int32 ID collision retries.
|
|
MaxEphemeralCreateAttempts = 8
|
|
// MaxEphemeralCallbackDataBytes is the Bot API callback_data wire limit.
|
|
MaxEphemeralCallbackDataBytes = 64
|
|
// MaxEphemeralCaptionLength follows the Bot API media-caption contract.
|
|
MaxEphemeralCaptionLength = 1024
|
|
// Rich messages are accepted at the domain boundary only within a bounded
|
|
// wire-sized snapshot. The current official client does not send this flag,
|
|
// but malformed callers must not be able to retain unbounded block vectors.
|
|
MaxEphemeralRichBlocksBytes = 1 << 20
|
|
MaxEphemeralRichMediaRefs = 100
|
|
)
|
|
|
|
var (
|
|
ErrEphemeralInvalid = errors.New("ephemeral message invalid")
|
|
ErrEphemeralNotFound = errors.New("ephemeral message not found")
|
|
ErrEphemeralExpired = errors.New("ephemeral message expired")
|
|
ErrEphemeralDeleted = errors.New("ephemeral message deleted")
|
|
ErrEphemeralIDCollision = errors.New("ephemeral message id collision")
|
|
ErrEphemeralRandomIDConflict = errors.New("ephemeral random id conflict")
|
|
ErrEphemeralVersionConflict = errors.New("ephemeral message version conflict")
|
|
ErrEphemeralReplyExpired = errors.New("ephemeral reply expired")
|
|
ErrEphemeralQueryInvalid = errors.New("ephemeral query invalid")
|
|
ErrEphemeralPeerInvalid = errors.New("ephemeral peer invalid")
|
|
ErrEphemeralSenderInvalid = errors.New("ephemeral sender invalid")
|
|
ErrEphemeralReceiverInvalid = errors.New("ephemeral receiver invalid")
|
|
ErrEphemeralCommandInvalid = errors.New("ephemeral command invalid")
|
|
ErrEphemeralForbidden = errors.New("ephemeral action forbidden")
|
|
ErrEphemeralDeviceMismatch = errors.New("ephemeral device mismatch")
|
|
ErrEphemeralCallbackInvalid = errors.New("ephemeral callback invalid")
|
|
)
|
|
|
|
// EphemeralDevice identifies the exact client application that originated an
|
|
// eligible action. BusinessAuthKeyID is the durable device identity; SessionID
|
|
// is retained for binding checks and diagnostics, not used as a global key.
|
|
type EphemeralDevice struct {
|
|
UserID int64
|
|
BusinessAuthKeyID [8]byte
|
|
SessionID int64
|
|
}
|
|
|
|
// EphemeralContent is the mutable presentation payload. Identity, routing and
|
|
// reply ancestry live on EphemeralMessage and never change during edits.
|
|
type EphemeralContent struct {
|
|
Message string
|
|
Entities []MessageEntity
|
|
Media *MessageMedia
|
|
ReplyMarkup *MessageReplyMarkup
|
|
RichMessage *MessageRichMessage
|
|
}
|
|
|
|
// EphemeralMessage is a short-lived bot/member interaction. It deliberately
|
|
// has no ordinary message box ID, pts, qts, seq, unread or dialog fields.
|
|
type EphemeralMessage struct {
|
|
ID int
|
|
Peer Peer
|
|
SenderUserID int64
|
|
ReceiverUserID int64
|
|
Date int
|
|
EditDate int
|
|
RandomID int64
|
|
TopMessageID int
|
|
ReplyToEphemeralID int
|
|
Content EphemeralContent
|
|
OriginDevice EphemeralDevice
|
|
PayloadHash [32]byte
|
|
Version uint64
|
|
Deleted bool
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
// BotAPIReply is a one-level, runtime-only reply snapshot. It is attached
|
|
// after the authoritative message has been written, excluded from Redis and
|
|
// broker JSON, and used only to project a valid Bot API reply_to_message.
|
|
BotAPIReply *EphemeralMessage `json:"-"`
|
|
}
|
|
|
|
type SendClientEphemeralRequest struct {
|
|
SenderUserID int64
|
|
ReceiverBotID int64
|
|
Peer Peer
|
|
QueryID int64
|
|
RandomID int64
|
|
TopMessageID int
|
|
ReplyToEphemeralID int
|
|
Content EphemeralContent
|
|
OriginDevice EphemeralDevice
|
|
}
|
|
|
|
type SendBotEphemeralRequest struct {
|
|
BotUserID int64
|
|
ReceiverUserID int64
|
|
Peer Peer
|
|
RandomID int64
|
|
TopMessageID int
|
|
ReplyToEphemeralID int
|
|
Content EphemeralContent
|
|
// ActionMessageID authorizes the ordinary 15-second response path. When it
|
|
// is zero the bot must be an administrator and delivery targets every ready
|
|
// Layer 228 device of ReceiverUserID.
|
|
ActionMessageID int
|
|
// CallbackQueryID authorizes a response to a callback originating from a
|
|
// bot→user ephemeral message. The shared action record owns the target device.
|
|
CallbackQueryID int64
|
|
}
|
|
|
|
type EphemeralCallback struct {
|
|
Message EphemeralMessage
|
|
BotUserID int64
|
|
UserID int64
|
|
Peer Peer
|
|
Data []byte
|
|
Device EphemeralDevice
|
|
OccurredAt time.Time
|
|
}
|
|
|
|
type EphemeralCallbackAction struct {
|
|
QueryID int64
|
|
BotUserID int64
|
|
UserID int64
|
|
Peer Peer
|
|
MessageID int
|
|
TopMessageID int
|
|
Device EphemeralDevice
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// EphemeralReportEvidence is the durable, device-identity-free snapshot kept
|
|
// for abuse review after the transient Redis record expires. It intentionally
|
|
// excludes OriginDevice, random IDs and session/auth-key identifiers.
|
|
type EphemeralReportEvidence struct {
|
|
MessageID int
|
|
Peer Peer
|
|
SenderUserID int64
|
|
ReceiverUserID int64
|
|
Date int
|
|
EditDate int
|
|
TopMessageID int
|
|
ReplyToEphemeralID int
|
|
Content EphemeralContent
|
|
PayloadHash [32]byte
|
|
Version uint64
|
|
}
|
|
|
|
// EphemeralAbuseReport is written only for a final report option. CommentHash
|
|
// makes retries idempotent without indexing potentially large user text.
|
|
type EphemeralAbuseReport struct {
|
|
ReporterUserID int64
|
|
Option string
|
|
Comment string
|
|
CommentHash [32]byte
|
|
Evidence EphemeralReportEvidence
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func NewEphemeralAbuseReport(reporterUserID int64, option, comment string, message EphemeralMessage, createdAt time.Time) EphemeralAbuseReport {
|
|
return EphemeralAbuseReport{
|
|
ReporterUserID: reporterUserID,
|
|
Option: option,
|
|
Comment: comment,
|
|
CommentHash: sha256.Sum256([]byte(comment)),
|
|
Evidence: EphemeralReportEvidence{
|
|
MessageID: message.ID, Peer: message.Peer,
|
|
SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID,
|
|
Date: message.Date, EditDate: message.EditDate,
|
|
TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID,
|
|
Content: message.Content, PayloadHash: message.PayloadHash, Version: message.Version,
|
|
},
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
func (r EphemeralAbuseReport) Validate() error {
|
|
if r.ReporterUserID <= 0 || r.Option == "" || len(r.Option) > 64 || utf8.RuneCountInString(r.Comment) > 4096 ||
|
|
r.Evidence.MessageID <= 0 || r.Evidence.MessageID > MaxMessageBoxID ||
|
|
r.Evidence.Peer.Type != PeerTypeChannel || r.Evidence.Peer.ID <= 0 ||
|
|
r.Evidence.SenderUserID <= 0 || r.Evidence.ReceiverUserID != r.ReporterUserID ||
|
|
r.Evidence.SenderUserID == r.Evidence.ReceiverUserID || r.CreatedAt.IsZero() ||
|
|
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type EditEphemeralFields struct {
|
|
SetMessage bool
|
|
Message string
|
|
Entities []MessageEntity
|
|
SetMedia bool
|
|
Media *MessageMedia
|
|
SetReplyMarkup bool
|
|
ReplyMarkup *MessageReplyMarkup
|
|
}
|
|
|
|
type BotAPIFileInput struct {
|
|
LocationKey string
|
|
RemoteURL string
|
|
FileName string
|
|
MimeType string
|
|
Bytes []byte
|
|
Width int
|
|
Height int
|
|
Duration int
|
|
Title string
|
|
Performer string
|
|
Emoji string
|
|
}
|
|
|
|
type BotAPIEphemeralSendInput struct {
|
|
BotUserID int64
|
|
ChatID int64
|
|
ReceiverUserID int64
|
|
CallbackQueryID int64
|
|
ReplyToEphemeralID int
|
|
TopMessageID int
|
|
Kind string
|
|
Text string
|
|
Entities []MessageEntity
|
|
ReplyMarkup *MessageReplyMarkup
|
|
File BotAPIFileInput
|
|
SecondaryFile BotAPIFileInput
|
|
DirectMedia *MessageMedia
|
|
}
|
|
|
|
type BotAPIEphemeralEditInput struct {
|
|
BotUserID int64
|
|
ChatID int64
|
|
ReceiverUserID int64
|
|
MessageID int
|
|
Mode EphemeralEditMode
|
|
Fields EditEphemeralFields
|
|
MediaKind string
|
|
File BotAPIFileInput
|
|
SecondaryFile BotAPIFileInput
|
|
}
|
|
|
|
type EphemeralEditMode string
|
|
|
|
const (
|
|
EphemeralEditText EphemeralEditMode = "text"
|
|
EphemeralEditMedia EphemeralEditMode = "media"
|
|
EphemeralEditCaption EphemeralEditMode = "caption"
|
|
EphemeralEditReplyMarkup EphemeralEditMode = "reply_markup"
|
|
)
|
|
|
|
func (m EphemeralMessage) ValidateForCreate(now time.Time) error {
|
|
if err := m.ValidateStored(); err != nil || m.Version != 1 || m.Deleted || !m.ExpiresAt.After(now) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m EphemeralMessage) ValidateStored() error {
|
|
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
|
|
m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID ||
|
|
m.RandomID == 0 || m.Date <= 0 || m.Version == 0 || m.CreatedAt.IsZero() || m.ExpiresAt.IsZero() ||
|
|
!m.ExpiresAt.After(m.CreatedAt) || m.ExpiresAt.Sub(m.CreatedAt) > EphemeralMessageRetention ||
|
|
m.Date != int(m.CreatedAt.Unix()) || (m.EditDate != 0 && m.EditDate < m.Date) ||
|
|
m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
|
|
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID ||
|
|
m.PayloadHash == ([32]byte{}) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
zeroDevice := m.OriginDevice == (EphemeralDevice{})
|
|
if !zeroDevice && (m.OriginDevice.UserID <= 0 || m.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
|
|
m.OriginDevice.SessionID == 0 ||
|
|
(m.OriginDevice.UserID != m.SenderUserID && m.OriginDevice.UserID != m.ReceiverUserID)) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
if m.Deleted {
|
|
if m.Version < 2 || m.Content.Message != "" || len(m.Content.Entities) != 0 || m.Content.Media != nil ||
|
|
m.Content.ReplyMarkup != nil || !m.Content.RichMessage.IsZero() {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
return nil
|
|
}
|
|
return ValidateEphemeralContent(m.Content)
|
|
}
|
|
|
|
func ValidateEphemeralContent(content EphemeralContent) error {
|
|
if !utf8.ValidString(content.Message) || utf8.RuneCountInString(content.Message) > MaxMessageTextLength ||
|
|
len(content.Entities) > MaxMessageEntityCount || !validEphemeralEntityBounds(content.Message, content.Entities) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
if err := ValidateReplyMarkup(content.ReplyMarkup); err != nil {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
if content.ReplyMarkup != nil && !content.ReplyMarkup.IsZero() && content.ReplyMarkup.Kind() != MessageReplyMarkupInline {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
if content.Media != nil && !validEphemeralMedia(content.Media) {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
if rich := content.RichMessage; !rich.IsZero() {
|
|
if len(rich.Blocks) == 0 || len(rich.Blocks) > MaxEphemeralRichBlocksBytes ||
|
|
len(rich.Photos) > MaxEphemeralRichMediaRefs || len(rich.Documents) > MaxEphemeralRichMediaRefs {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
}
|
|
if content.Message == "" && content.Media == nil && content.RichMessage.IsZero() {
|
|
return ErrEphemeralInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validEphemeralEntityBounds(message string, entities []MessageEntity) bool {
|
|
utf16Length := 0
|
|
for _, value := range message {
|
|
utf16Length++
|
|
if value > 0xffff {
|
|
utf16Length++
|
|
}
|
|
}
|
|
for _, entity := range entities {
|
|
if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length ||
|
|
entity.Length > utf16Length-entity.Offset {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validEphemeralMedia(media *MessageMedia) bool {
|
|
if media == nil || media.IsZero() || media.ServiceAction != nil || media.Dice != nil || media.Poll != nil ||
|
|
media.GeoLive != nil || media.Todo != nil || media.Story != nil || media.WebPage != nil {
|
|
return false
|
|
}
|
|
switch media.Kind {
|
|
case MessageMediaKindPhoto:
|
|
return media.Photo != nil && media.Document == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
|
|
case MessageMediaKindDocument:
|
|
return media.Document != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
|
|
case MessageMediaKindContact:
|
|
return media.Contact != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Geo == nil && media.Venue == nil
|
|
case MessageMediaKindGeo:
|
|
return media.Geo != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Venue == nil
|
|
case MessageMediaKindVenue:
|
|
return media.Venue != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Geo == nil
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m EphemeralMessage) Expired(now time.Time) bool {
|
|
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
|
|
}
|