Ctrl K
ComunidadGuías…

internal/domain/ephemeral.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
}