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
|
|
@ -92,6 +92,7 @@ const (
|
|||
type BotCommand struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
Ephemeral bool `json:"ephemeral,omitempty"`
|
||||
}
|
||||
|
||||
// BotMenuButtonType 标识菜单按钮类型。
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// BotAPIUpdateKind is the Bot API delivery shape for a queued update.
|
||||
type BotAPIUpdateKind string
|
||||
|
||||
|
|
@ -22,6 +24,109 @@ type BotCallbackQuery struct {
|
|||
InlineMessage *BotInlineMessageID
|
||||
}
|
||||
|
||||
// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot.
|
||||
// Ordinary queued messages are reloaded from their durable message tables;
|
||||
// ephemeral messages have no such table and therefore travel in this explicit
|
||||
// envelope instead of overloading SourcePts or an ordinary message id. The
|
||||
// public shape deliberately cannot represent random IDs, payload hashes,
|
||||
// auth-key/session identifiers, or the originating device.
|
||||
type BotAPIEphemeralPayload struct {
|
||||
Message BotAPIEphemeralMessage
|
||||
ReplyTo *BotAPIEphemeralMessage `json:",omitempty"`
|
||||
}
|
||||
|
||||
type BotAPIEphemeralMessage struct {
|
||||
ID int
|
||||
Peer Peer
|
||||
SenderUserID int64
|
||||
ReceiverUserID int64
|
||||
Date int
|
||||
EditDate int
|
||||
TopMessageID int
|
||||
ReplyToEphemeralID int
|
||||
Content EphemeralContent
|
||||
Version uint64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewBotAPIEphemeralPayload(message EphemeralMessage) *BotAPIEphemeralPayload {
|
||||
payload := &BotAPIEphemeralPayload{Message: publicBotAPIEphemeralMessage(message)}
|
||||
if message.BotAPIReply != nil {
|
||||
reply := publicBotAPIEphemeralMessage(*message.BotAPIReply)
|
||||
payload.ReplyTo = &reply
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicBotAPIEphemeralMessage(message EphemeralMessage) BotAPIEphemeralMessage {
|
||||
return BotAPIEphemeralMessage{
|
||||
ID: 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, Version: message.Version, ExpiresAt: message.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m BotAPIEphemeralMessage) EphemeralMessage() EphemeralMessage {
|
||||
return EphemeralMessage{
|
||||
ID: m.ID, Peer: m.Peer,
|
||||
SenderUserID: m.SenderUserID, ReceiverUserID: m.ReceiverUserID,
|
||||
Date: m.Date, EditDate: m.EditDate,
|
||||
TopMessageID: m.TopMessageID, ReplyToEphemeralID: m.ReplyToEphemeralID,
|
||||
Content: m.Content, Version: m.Version, ExpiresAt: m.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (p BotAPIEphemeralPayload) EphemeralMessage() EphemeralMessage {
|
||||
message := p.Message.EphemeralMessage()
|
||||
if p.ReplyTo != nil {
|
||||
reply := p.ReplyTo.EphemeralMessage()
|
||||
message.BotAPIReply = &reply
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func (p BotAPIEphemeralPayload) Validate() error {
|
||||
if err := p.Message.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Message.ReplyToEphemeralID == 0 {
|
||||
if p.ReplyTo != nil {
|
||||
return ErrEphemeralInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if p.ReplyTo == nil || p.ReplyTo.Validate() != nil || p.ReplyTo.ID != p.Message.ReplyToEphemeralID ||
|
||||
p.ReplyTo.Peer != p.Message.Peer || p.ReplyTo.Date > p.Message.Date ||
|
||||
!sameEphemeralParticipantPair(p.Message.SenderUserID, p.Message.ReceiverUserID, p.ReplyTo.SenderUserID, p.ReplyTo.ReceiverUserID) {
|
||||
return ErrEphemeralInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameEphemeralParticipantPair(firstSender, firstReceiver, secondSender, secondReceiver int64) bool {
|
||||
return (firstSender == secondSender && firstReceiver == secondReceiver) ||
|
||||
(firstSender == secondReceiver && firstReceiver == secondSender)
|
||||
}
|
||||
|
||||
func (m BotAPIEphemeralMessage) Expired(now time.Time) bool {
|
||||
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
|
||||
}
|
||||
|
||||
func (m BotAPIEphemeralMessage) Validate() error {
|
||||
date := time.Unix(int64(m.Date), 0)
|
||||
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.Date <= 0 || m.Version == 0 || m.ExpiresAt.IsZero() || !m.ExpiresAt.After(date) ||
|
||||
m.ExpiresAt.Sub(date) > EphemeralMessageRetention+time.Second ||
|
||||
(m.EditDate != 0 && m.EditDate < m.Date) || m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
|
||||
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID {
|
||||
return ErrEphemeralInvalid
|
||||
}
|
||||
return ValidateEphemeralContent(m.Content)
|
||||
}
|
||||
|
||||
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
|
||||
// It can be projected both to MTProto and to Bot API's opaque
|
||||
// inline_message_id without leaking tg types into the store boundary.
|
||||
|
|
@ -44,6 +149,7 @@ type BotAPIUpdate struct {
|
|||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
Ephemeral *BotAPIEphemeralPayload
|
||||
}
|
||||
|
||||
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
|
||||
|
|
@ -56,4 +162,5 @@ type EnqueueBotAPIUpdateRequest struct {
|
|||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
Ephemeral *BotAPIEphemeralPayload
|
||||
}
|
||||
|
|
|
|||
41
internal/domain/botapi_update_test.go
Normal file
41
internal/domain/botapi_update_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBotAPIEphemeralPayloadCannotSerializePrivateRoutingState(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
reply := EphemeralMessage{
|
||||
ID: 16, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
|
||||
SenderUserID: 3001, ReceiverUserID: 2001, Date: int(now.Unix()) - 1,
|
||||
Content: EphemeralContent{Message: "prompt"}, Version: 1, ExpiresAt: now.Add(EphemeralMessageRetention),
|
||||
}
|
||||
payload := NewBotAPIEphemeralPayload(EphemeralMessage{
|
||||
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
|
||||
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()),
|
||||
RandomID: 99, ReplyToEphemeralID: reply.ID, Content: EphemeralContent{Message: "private"},
|
||||
OriginDevice: EphemeralDevice{UserID: 2001, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
|
||||
PayloadHash: [32]byte{5, 6, 7}, Version: 1,
|
||||
CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), BotAPIReply: &reply,
|
||||
})
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, privateField := range [][]byte{
|
||||
[]byte("RandomID"), []byte("OriginDevice"), []byte("BusinessAuthKeyID"),
|
||||
[]byte("SessionID"), []byte("PayloadHash"), []byte("CreatedAt"),
|
||||
} {
|
||||
if bytes.Contains(raw, privateField) {
|
||||
t.Fatalf("durable Bot API envelope leaked %s: %s", privateField, raw)
|
||||
}
|
||||
}
|
||||
if payload.Validate() != nil || payload.Message.ID != 17 || payload.Message.Content.Message != "private" || payload.Message.ExpiresAt.IsZero() ||
|
||||
payload.ReplyTo == nil || payload.ReplyTo.ID != reply.ID {
|
||||
t.Fatalf("public payload=%+v", payload)
|
||||
}
|
||||
}
|
||||
362
internal/domain/ephemeral.go
Normal file
362
internal/domain/ephemeral.go
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
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)
|
||||
}
|
||||
64
internal/domain/ephemeral_test.go
Normal file
64
internal/domain/ephemeral_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateEphemeralContentBoundsAllRetainedVectors(t *testing.T) {
|
||||
valid := EphemeralContent{
|
||||
Message: "hi 👋",
|
||||
Entities: []MessageEntity{{Type: MessageEntityBold, Offset: 0, Length: 2}},
|
||||
ReplyMarkup: &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{
|
||||
Type: MarkupButtonCallback, Text: "OK", Data: []byte("ok"),
|
||||
}}}},
|
||||
}
|
||||
if err := ValidateEphemeralContent(valid); err != nil {
|
||||
t.Fatalf("valid content: %v", err)
|
||||
}
|
||||
|
||||
badBounds := valid
|
||||
badBounds.Entities = []MessageEntity{{Type: MessageEntityBold, Offset: 5, Length: 2}}
|
||||
if err := ValidateEphemeralContent(badBounds); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("entity bounds err=%v", err)
|
||||
}
|
||||
badKeyboard := valid
|
||||
badKeyboard.ReplyMarkup = &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{
|
||||
Type: MarkupButtonText, Text: "public keyboard",
|
||||
}}}}
|
||||
if err := ValidateEphemeralContent(badKeyboard); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("reply keyboard err=%v", err)
|
||||
}
|
||||
badRich := EphemeralContent{RichMessage: &MessageRichMessage{Blocks: make([]byte, MaxEphemeralRichBlocksBytes+1)}}
|
||||
if err := ValidateEphemeralContent(badRich); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("rich bound err=%v", err)
|
||||
}
|
||||
badMedia := EphemeralContent{Media: &MessageMedia{Kind: MessageMediaKindPhoto}}
|
||||
if err := ValidateEphemeralContent(badMedia); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("media shape err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralStoredStateRejectsPartialDeviceAndInvalidTombstone(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
message := EphemeralMessage{
|
||||
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
|
||||
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), RandomID: 9,
|
||||
Content: EphemeralContent{Message: "private"}, OriginDevice: EphemeralDevice{UserID: 3001},
|
||||
PayloadHash: [32]byte{1}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention),
|
||||
}
|
||||
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("partial device err=%v", err)
|
||||
}
|
||||
message.OriginDevice = EphemeralDevice{}
|
||||
message.Deleted = true
|
||||
message.Content = EphemeralContent{}
|
||||
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
|
||||
t.Fatalf("version-one tombstone err=%v", err)
|
||||
}
|
||||
message.Version = 2
|
||||
if err := message.ValidateStored(); err != nil {
|
||||
t.Fatalf("valid tombstone err=%v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -716,25 +716,26 @@ type MessageStarGiftOfferDeclinedAction struct {
|
|||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
type MessageMedia struct {
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Contact *MessageContact `json:"contact,omitempty"`
|
||||
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
|
||||
Geo *MessageGeoPoint `json:"geo,omitempty"`
|
||||
Venue *MessageVenue `json:"venue,omitempty"`
|
||||
Dice *MessageDice `json:"dice,omitempty"`
|
||||
Poll *MessagePoll `json:"poll,omitempty"`
|
||||
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
|
||||
Todo *MessageTodo `json:"todo,omitempty"`
|
||||
Story *MessageStory `json:"story,omitempty"`
|
||||
WebPage *MessageWebPage `json:"web_page,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
LivePhotoVideo *Document `json:"live_photo_video,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Contact *MessageContact `json:"contact,omitempty"`
|
||||
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
|
||||
Geo *MessageGeoPoint `json:"geo,omitempty"`
|
||||
Venue *MessageVenue `json:"venue,omitempty"`
|
||||
Dice *MessageDice `json:"dice,omitempty"`
|
||||
Poll *MessagePoll `json:"poll,omitempty"`
|
||||
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
|
||||
Todo *MessageTodo `json:"todo,omitempty"`
|
||||
Story *MessageStory `json:"story,omitempty"`
|
||||
WebPage *MessageWebPage `json:"web_page,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
// InvertMedia 映射 message.invert_media:媒体(典型为链接预览)渲染在文本上方。
|
||||
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
|
||||
InvertMedia bool `json:"invert_media,omitempty"`
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ type UpdateEvent struct {
|
|||
QuickReply QuickReply
|
||||
QuickReplyMessage QuickReplyMessage
|
||||
BotCallbackQuery *BotCallbackQuery
|
||||
// BotAPIUpdateID is the HTTP Bot API update_id. It is intentionally separate
|
||||
// from MTProto Pts: Bot API ephemeral envelopes never advance account state.
|
||||
BotAPIUpdateID int64
|
||||
EphemeralMessage *EphemeralMessage
|
||||
}
|
||||
|
||||
// LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue