Ctrl K
ComunidadGuías…

internal/store/private_send_idempotency.go

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

Archivo original ↗Preguntar a KiBot
package store

import (
	"bytes"
	"crypto/sha256"
	"encoding/json"
	"fmt"

	"telesrv/internal/domain"
)

const privateSendFingerprintVersion = 1

const channelSendFingerprintVersion = 1

const sendSnapshotVersion = 1

type privateSendSnapshotEnvelope struct {
	Version int            `json:"version"`
	Message domain.Message `json:"message"`
}

type channelSendSnapshotEnvelope struct {
	Version int                   `json:"version"`
	Message domain.ChannelMessage `json:"message"`
}

// privateSendFingerprintPayload contains only the client's immutable intent for one send.
// Date, origin auth/session, current block state, and automation metadata are all execution
// environment and must not make the same request become a different logical message after a
// reconnect or state change. sender/random_id are constrained separately by the idempotency
// index key.
type privateSendFingerprintPayload struct {
	Version         int                        `json:"version"`
	RecipientUserID int64                      `json:"recipient_user_id"`
	Message         string                     `json:"message"`
	Entities        []domain.MessageEntity     `json:"entities"`
	Media           *domain.MessageMedia       `json:"media"`
	Silent          bool                       `json:"silent"`
	NoForwards      bool                       `json:"noforwards"`
	ReplyTo         *domain.MessageReply       `json:"reply_to,omitempty"`
	Forward         *domain.MessageForward     `json:"forward,omitempty"`
	TTLPeriod       int                        `json:"ttl_period"`
	ViaBotID        int64                      `json:"via_bot_id"`
	GroupedID       int64                      `json:"grouped_id"`
	Effect          int64                      `json:"effect"`
	ReplyMarkup     *domain.MessageReplyMarkup `json:"reply_markup"`
	RichMessage     *domain.MessageRichMessage `json:"rich_message"`
}

// channelSendFingerprintPayload contains only the durable intent of an internal channel send.
// Operational projection fields (Date, recipient/mention lists, PostAuthor and lookup hints) are
// intentionally absent: they may change after the first commit and cannot redefine a replay.
type channelSendFingerprintPayload struct {
	Version     int                          `json:"version"`
	ChannelID   int64                        `json:"channel_id"`
	Message     string                       `json:"message"`
	Entities    []domain.MessageEntity       `json:"entities"`
	Media       *domain.MessageMedia         `json:"media"`
	Silent      bool                         `json:"silent"`
	NoForwards  bool                         `json:"noforwards"`
	ReplyTo     *domain.MessageReply         `json:"reply_to,omitempty"`
	Forward     *domain.MessageForward       `json:"forward,omitempty"`
	ViaBotID    int64                        `json:"via_bot_id"`
	GroupedID   int64                        `json:"grouped_id"`
	ReplyMarkup *domain.MessageReplyMarkup   `json:"reply_markup"`
	RichMessage *domain.MessageRichMessage   `json:"rich_message"`
	SendAs      *domain.Peer                 `json:"send_as,omitempty"`
	Action      *domain.ChannelMessageAction `json:"action,omitempty"`
	TTLPeriod   int                          `json:"ttl_period"`
}

type monoforumSendFingerprintPayload struct {
	Version        int                    `json:"version"`
	ChannelID      int64                  `json:"channel_id"`
	SavedPeer      domain.Peer            `json:"saved_peer"`
	Message        string                 `json:"message"`
	Entities       []domain.MessageEntity `json:"entities"`
	Media          *domain.MessageMedia   `json:"media"`
	ReplyTo        *domain.MessageReply   `json:"reply_to"`
	Forward        *domain.MessageForward `json:"forward,omitempty"`
	Silent         bool                   `json:"silent"`
	NoForwards     bool                   `json:"noforwards"`
	SuggestedPost  *domain.SuggestedPost  `json:"suggested_post,omitempty"`
	AllowPaidStars int64                  `json:"allow_paid_stars"`
}

// PrivateSendFingerprint returns a SHA-256 fingerprint of the original send
// intent. RPC callers should supply their precomputed raw-TL fingerprint;
// internal callers get a deterministic domain-level fallback.
func PrivateSendFingerprint(req domain.SendPrivateTextRequest) ([]byte, error) {
	if len(req.IdempotencyFingerprint) > 0 {
		if len(req.IdempotencyFingerprint) != sha256.Size {
			return nil, fmt.Errorf("private send idempotency fingerprint: got %d bytes, want %d", len(req.IdempotencyFingerprint), sha256.Size)
		}
		return append([]byte(nil), req.IdempotencyFingerprint...), nil
	}
	payload, err := json.Marshal(privateSendFingerprintPayload{
		Version:         privateSendFingerprintVersion,
		RecipientUserID: req.RecipientUserID,
		Message:         req.Message,
		Entities:        req.Entities,
		Media:           req.Media,
		Silent:          req.Silent,
		NoForwards:      req.NoForwards,
		ReplyTo:         req.ReplyTo,
		Forward:         req.Forward,
		TTLPeriod:       req.TTLPeriod,
		ViaBotID:        req.ViaBotID,
		GroupedID:       req.GroupedID,
		Effect:          req.Effect,
		ReplyMarkup:     req.ReplyMarkup,
		RichMessage:     req.RichMessage,
	})
	if err != nil {
		return nil, fmt.Errorf("marshal private send fingerprint: %w", err)
	}
	sum := sha256.Sum256(payload)
	return sum[:], nil
}

// ChannelSendFingerprint returns the request-boundary SHA-256 value when present, otherwise a
// deterministic domain fallback for app/Bot API callers that do not originate from a TL request.
func ChannelSendFingerprint(req domain.SendChannelMessageRequest) ([]byte, error) {
	if len(req.IdempotencyFingerprint) > 0 {
		if err := ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send"); err != nil {
			return nil, err
		}
		return append([]byte(nil), req.IdempotencyFingerprint...), nil
	}
	payload, err := json.Marshal(channelSendFingerprintPayload{
		Version:     channelSendFingerprintVersion,
		ChannelID:   req.ChannelID,
		Message:     req.Message,
		Entities:    req.Entities,
		Media:       req.Media,
		Silent:      req.Silent,
		NoForwards:  req.NoForwards,
		ReplyTo:     req.ReplyTo,
		Forward:     req.Forward,
		ViaBotID:    req.ViaBotID,
		GroupedID:   req.GroupedID,
		ReplyMarkup: req.ReplyMarkup,
		RichMessage: req.RichMessage,
		SendAs:      req.SendAs,
		Action:      req.Action,
		TTLPeriod:   req.TTLPeriod,
	})
	if err != nil {
		return nil, fmt.Errorf("marshal channel send fingerprint: %w", err)
	}
	sum := sha256.Sum256(payload)
	return sum[:], nil
}

// MonoforumSendFingerprint is scoped to one subscriber sub-dialog. SavedPeer is also part of the
// lookup key, but retaining it in the fallback prevents a future index/scope regression from
// silently accepting a cross-dialog replay.
func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, error) {
	if len(req.IdempotencyFingerprint) > 0 {
		if err := ValidateSendFingerprint(req.IdempotencyFingerprint, "monoforum send"); err != nil {
			return nil, err
		}
		return append([]byte(nil), req.IdempotencyFingerprint...), nil
	}
	payload, err := json.Marshal(monoforumSendFingerprintPayload{
		Version:        channelSendFingerprintVersion,
		ChannelID:      req.MonoforumID,
		SavedPeer:      req.SavedPeer,
		Message:        req.Message,
		Entities:       req.Entities,
		Media:          req.Media,
		ReplyTo:        req.ReplyTo,
		Forward:        req.Forward,
		Silent:         req.Silent,
		NoForwards:     req.NoForwards,
		SuggestedPost:  req.SuggestedPost,
		AllowPaidStars: req.AllowPaidStars,
	})
	if err != nil {
		return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err)
	}
	sum := sha256.Sum256(payload)
	return sum[:], nil
}

// ValidateSendFingerprint rejects empty, truncated and oversized receipts. Callers must never
// guess a legacy/corrupt fingerprint from a mutable message projection.
func ValidateSendFingerprint(fingerprint []byte, operation string) error {
	if len(fingerprint) != sha256.Size {
		return fmt.Errorf("%s idempotency fingerprint: got %d bytes, want %d", operation, len(fingerprint), sha256.Size)
	}
	return nil
}

// SameSendFingerprint requires two complete SHA-256 values.
func SameSendFingerprint(stored, expected []byte) bool {
	return len(stored) == sha256.Size && len(expected) == sha256.Size && bytes.Equal(stored, expected)
}

// SamePrivateSendFingerprint requires two complete SHA-256 values. Legacy or
// corrupt empty values are never guessed from mutable message projections.
func SamePrivateSendFingerprint(stored, expected []byte) bool {
	return SameSendFingerprint(stored, expected)
}

// EncodePrivateSendSnapshot freezes the sender-visible message returned by the
// first successful send. The snapshot is independent from mutable message-box
// projections so an edit or delete cannot erase the facts needed to acknowledge
// a later lost-response replay.
func EncodePrivateSendSnapshot(msg domain.Message) ([]byte, error) {
	if msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 {
		return nil, fmt.Errorf("private send snapshot: invalid id=%d uid=%d random_id=%d owner=%d pts=%d", msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts)
	}
	if msg.ReplyTo != nil && msg.ReplyTo.External != nil {
		if _, err := domain.EncodeMessageReplyExternal(msg.ReplyTo.External); err != nil {
			return nil, fmt.Errorf("private send snapshot reply: %w", err)
		}
	}
	raw, err := json.Marshal(privateSendSnapshotEnvelope{Version: sendSnapshotVersion, Message: msg})
	if err != nil {
		return nil, fmt.Errorf("marshal private send snapshot: %w", err)
	}
	return raw, nil
}

// DecodePrivateSendSnapshot returns a fresh object graph on every replay. Empty
// legacy rows are rejected rather than reconstructed from an edited projection.
func DecodePrivateSendSnapshot(raw []byte) (domain.Message, error) {
	var envelope privateSendSnapshotEnvelope
	if err := json.Unmarshal(raw, &envelope); err != nil {
		return domain.Message{}, fmt.Errorf("unmarshal private send snapshot: %w", err)
	}
	msg := envelope.Message
	if envelope.Version != sendSnapshotVersion || msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 {
		return domain.Message{}, fmt.Errorf("private send snapshot: invalid version=%d id=%d uid=%d random_id=%d owner=%d pts=%d", envelope.Version, msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts)
	}
	if msg.ReplyTo != nil && msg.ReplyTo.External != nil {
		if _, err := domain.EncodeMessageReplyExternal(msg.ReplyTo.External); err != nil {
			return domain.Message{}, fmt.Errorf("private send snapshot reply: %w", err)
		}
	}
	return msg, nil
}

// EncodeChannelSendSnapshot freezes the first sender echo for random_id replay.
func EncodeChannelSendSnapshot(msg domain.ChannelMessage) ([]byte, error) {
	if msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.Pts <= 0 {
		return nil, fmt.Errorf("channel send snapshot: invalid channel=%d id=%d random_id=%d sender=%d pts=%d", msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, msg.Pts)
	}
	raw, err := json.Marshal(channelSendSnapshotEnvelope{Version: sendSnapshotVersion, Message: msg})
	if err != nil {
		return nil, fmt.Errorf("marshal channel send snapshot: %w", err)
	}
	return raw, nil
}

// DecodeChannelSendSnapshot returns a fresh immutable first-send projection.
func DecodeChannelSendSnapshot(raw []byte) (domain.ChannelMessage, error) {
	var envelope channelSendSnapshotEnvelope
	if err := json.Unmarshal(raw, &envelope); err != nil {
		return domain.ChannelMessage{}, fmt.Errorf("unmarshal channel send snapshot: %w", err)
	}
	msg := envelope.Message
	if envelope.Version != sendSnapshotVersion || msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.Pts <= 0 {
		return domain.ChannelMessage{}, fmt.Errorf("channel send snapshot: invalid version=%d channel=%d id=%d random_id=%d sender=%d pts=%d", envelope.Version, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, msg.Pts)
	}
	return msg, nil
}