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:
A 2026-07-20 16:43:27 +08:00
parent 3f78eaa2c6
commit f49c817def
53 changed files with 5793 additions and 112 deletions

View file

@ -253,6 +253,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
SourcePts: req.SourcePts,
Date: req.Date,
Callback: cloneBotAPICallback(req.Callback),
Ephemeral: cloneBotAPIEphemeral(req.Ephemeral),
}
s.nextID++
s.rows = append(s.rows, row)
@ -433,6 +434,17 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
}
if req.Ephemeral != nil {
message := req.Ephemeral.Message
if req.Ephemeral.Validate() != nil || message.ID != req.MessageID || message.Peer != req.Peer || message.Expired(time.Unix(int64(req.Date), 0)) ||
req.Peer.Type != domain.PeerTypeChannel || req.SourcePts != 0 {
return fmt.Errorf("invalid bot api ephemeral update")
}
if (req.Kind == domain.BotAPIUpdateCallbackQuery && message.SenderUserID != req.BotUserID) ||
(req.Kind != domain.BotAPIUpdateCallbackQuery && message.ReceiverUserID != req.BotUserID) {
return fmt.Errorf("invalid bot api ephemeral target")
}
}
if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
@ -457,14 +469,25 @@ func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string {
if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil {
return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID)
}
if req.Ephemeral != nil {
return fmt.Sprintf("%d:%s:ephemeral:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.Ephemeral.Message.Version)
}
return fmt.Sprintf("%d:%s:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.SourcePts)
}
func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate {
row.Callback = cloneBotAPICallback(row.Callback)
row.Ephemeral = cloneBotAPIEphemeral(row.Ephemeral)
return row
}
func cloneBotAPIEphemeral(in *domain.BotAPIEphemeralPayload) *domain.BotAPIEphemeralPayload {
if in == nil {
return nil
}
return domain.NewBotAPIEphemeralPayload(cloneEphemeralMessage(in.EphemeralMessage()))
}
func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery {
if in == nil {
return nil

View file

@ -189,3 +189,57 @@ func TestBotAPIInlineCallbackRoundTrip(t *testing.T) {
t.Fatalf("inline callback rows=%#v err=%v", rows, err)
}
}
func TestBotAPIEphemeralMessageVersionsAndCallbackRoundTrip(t *testing.T) {
ctx := context.Background()
store := NewBotAPIUpdateStore()
now := time.Now()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}
incoming := domain.EphemeralMessage{
ID: 71, Peer: peer, SenderUserID: 2001, ReceiverUserID: 1001,
Date: int(now.Unix()), RandomID: 1, Content: domain.EphemeralContent{Message: "/private"},
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
request := domain.EnqueueBotAPIUpdateRequest{
BotUserID: 1001, Kind: domain.BotAPIUpdateMessage, Peer: peer,
MessageID: incoming.ID, Date: incoming.Date,
Ephemeral: domain.NewBotAPIEphemeralPayload(incoming),
}
first, created, err := store.EnqueueBotAPIUpdate(ctx, request)
if err != nil || !created || first.SourcePts != 0 || first.Ephemeral == nil {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
incoming.Version = 2
incoming.EditDate = incoming.Date + 1
incoming.Content.Message = "edited"
request.Kind = domain.BotAPIUpdateEditedMessage
request.Ephemeral = domain.NewBotAPIEphemeralPayload(incoming)
edited, created, err := store.EnqueueBotAPIUpdate(ctx, request)
if err != nil || !created || edited.ID <= first.ID {
t.Fatalf("edited=%+v created=%v err=%v", edited, created, err)
}
outgoing := incoming
outgoing.ID, outgoing.SenderUserID, outgoing.ReceiverUserID = 72, 1001, 2001
outgoing.Version, outgoing.Content.Message = 1, "button"
callback := &domain.BotCallbackQuery{
ID: 9001, BotUserID: 1001, UserID: 2001, Peer: peer,
MessageID: outgoing.ID, ChatInstance: 901, Data: []byte("tap"),
}
callbackRow, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Peer: peer,
MessageID: outgoing.ID, Date: outgoing.Date, Callback: callback,
Ephemeral: domain.NewBotAPIEphemeralPayload(outgoing),
})
if err != nil || !created || callbackRow.Callback == nil || callbackRow.Ephemeral == nil {
t.Fatalf("callback=%+v created=%v err=%v", callbackRow, created, err)
}
rows, err := store.ListBotAPIUpdates(ctx, 1001, first.ID, 100)
if err != nil || len(rows) != 3 || rows[0].Ephemeral.Message.Content.Message != "/private" ||
rows[1].Ephemeral.Message.Content.Message != "edited" || string(rows[2].Callback.Data) != "tap" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
}

View file

@ -0,0 +1,380 @@
package memory
import (
"container/heap"
"context"
"sync"
"sync/atomic"
"time"
"telesrv/internal/domain"
)
const ephemeralShardCount = 64
type ephemeralMessageKey struct {
peerType domain.PeerType
peerID int64
id int
}
type ephemeralRandomKey struct {
peerType domain.PeerType
peerID int64
senderID int64
receiverID int64
randomID int64
}
type ephemeralEntry struct {
message domain.EphemeralMessage
generation uint64
}
type ephemeralExpiry struct {
key ephemeralMessageKey
expiresAt int64
generation uint64
}
type ephemeralExpiryHeap []ephemeralExpiry
func (h ephemeralExpiryHeap) Len() int { return len(h) }
func (h ephemeralExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt }
func (h ephemeralExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *ephemeralExpiryHeap) Push(value any) {
*h = append(*h, value.(ephemeralExpiry))
}
func (h *ephemeralExpiryHeap) Pop() any {
old := *h
n := len(old)
value := old[n-1]
old[n-1] = ephemeralExpiry{}
*h = old[:n-1]
return value
}
type ephemeralShard struct {
mu sync.RWMutex
messages map[ephemeralMessageKey]ephemeralEntry
random map[ephemeralRandomKey]ephemeralMessageKey
expiry ephemeralExpiryHeap
nextGeneration uint64
}
type ephemeralCallbackActionShard struct {
mu sync.RWMutex
actions map[int64]ephemeralCallbackActionEntry
expiry ephemeralCallbackExpiryHeap
nextGeneration uint64
}
type ephemeralCallbackActionEntry struct {
action domain.EphemeralCallbackAction
generation uint64
}
type ephemeralCallbackExpiry struct {
queryID int64
expiresAt int64
generation uint64
}
type ephemeralCallbackExpiryHeap []ephemeralCallbackExpiry
func (h ephemeralCallbackExpiryHeap) Len() int { return len(h) }
func (h ephemeralCallbackExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt }
func (h ephemeralCallbackExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *ephemeralCallbackExpiryHeap) Push(value any) {
*h = append(*h, value.(ephemeralCallbackExpiry))
}
func (h *ephemeralCallbackExpiryHeap) Pop() any {
old := *h
n := len(old)
value := old[n-1]
old[n-1] = ephemeralCallbackExpiry{}
*h = old[:n-1]
return value
}
// EphemeralMessageStore shards by peer. A create touches one shard, so the ID
// and random-ID indexes can be updated atomically without a process-wide lock.
type EphemeralMessageStore struct {
shards [ephemeralShardCount]ephemeralShard
callbackActions [ephemeralShardCount]ephemeralCallbackActionShard
messageCursor atomic.Uint32
callbackCursor atomic.Uint32
}
func NewEphemeralMessageStore() *EphemeralMessageStore {
s := &EphemeralMessageStore{}
for i := range s.shards {
s.shards[i].messages = make(map[ephemeralMessageKey]ephemeralEntry)
s.shards[i].random = make(map[ephemeralRandomKey]ephemeralMessageKey)
s.callbackActions[i].actions = make(map[int64]ephemeralCallbackActionEntry)
}
return s
}
func (s *EphemeralMessageStore) PutEphemeralCallbackAction(_ context.Context, action domain.EphemeralCallbackAction) (bool, error) {
if action.QueryID == 0 || action.BotUserID <= 0 || action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel ||
action.Peer.ID <= 0 || action.MessageID <= 0 || action.Device.UserID != action.UserID ||
action.Device.BusinessAuthKeyID == ([8]byte{}) || action.CreatedAt.IsZero() || !action.ExpiresAt.After(action.CreatedAt) ||
action.ExpiresAt.Sub(action.CreatedAt) > domain.EphemeralReplyWindow {
return false, domain.ErrEphemeralInvalid
}
shard := &s.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)]
shard.mu.Lock()
defer shard.mu.Unlock()
if existing, ok := shard.actions[action.QueryID]; ok && action.CreatedAt.Before(existing.action.ExpiresAt) {
return false, nil
}
shard.nextGeneration++
entry := ephemeralCallbackActionEntry{action: action, generation: shard.nextGeneration}
shard.actions[action.QueryID] = entry
heap.Push(&shard.expiry, ephemeralCallbackExpiry{
queryID: action.QueryID, expiresAt: action.ExpiresAt.UnixNano(), generation: entry.generation,
})
return true, nil
}
func (s *EphemeralMessageStore) GetEphemeralCallbackAction(_ context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) {
if botUserID <= 0 || queryID == 0 {
return domain.EphemeralCallbackAction{}, false, nil
}
shard := &s.callbackActions[uint64(queryID)&(ephemeralShardCount-1)]
shard.mu.RLock()
entry, ok := shard.actions[queryID]
if ok && entry.action.BotUserID == botUserID && now.Before(entry.action.ExpiresAt) {
shard.mu.RUnlock()
return entry.action, true, nil
}
shard.mu.RUnlock()
if !ok || entry.action.BotUserID != botUserID {
return domain.EphemeralCallbackAction{}, false, nil
}
shard.mu.Lock()
if current, exists := shard.actions[queryID]; exists && !now.Before(current.action.ExpiresAt) {
delete(shard.actions, queryID)
}
shard.mu.Unlock()
return domain.EphemeralCallbackAction{}, false, nil
}
func (s *EphemeralMessageStore) CreateEphemeralMessage(_ context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
now := message.CreatedAt
if err := message.ValidateForCreate(now); err != nil {
return domain.EphemeralMessage{}, false, err
}
shard := s.shard(message.Peer)
messageKey := ephemeralKey(message.Peer, message.ID)
randomKey := ephemeralRandom(message)
shard.mu.Lock()
defer shard.mu.Unlock()
if existingKey, ok := shard.random[randomKey]; ok {
if existing, found := shard.messages[existingKey]; found && !existing.message.Expired(now) {
if existing.message.PayloadHash != message.PayloadHash {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict
}
return cloneEphemeralMessage(existing.message), false, nil
}
delete(shard.random, randomKey)
delete(shard.messages, existingKey)
}
if existing, ok := shard.messages[messageKey]; ok {
if !existing.message.Expired(now) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
}
delete(shard.random, ephemeralRandom(existing.message))
delete(shard.messages, messageKey)
}
stored := cloneEphemeralMessage(message)
stored.BotAPIReply = nil
shard.nextGeneration++
entry := ephemeralEntry{message: stored, generation: shard.nextGeneration}
shard.messages[messageKey] = entry
shard.random[randomKey] = messageKey
heap.Push(&shard.expiry, ephemeralExpiry{
key: messageKey,
expiresAt: stored.ExpiresAt.UnixNano(),
generation: entry.generation,
})
return cloneEphemeralMessage(stored), true, nil
}
func (s *EphemeralMessageStore) GetEphemeralMessage(_ context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) {
key := ephemeralKey(peer, id)
shard := s.shard(peer)
shard.mu.RLock()
entry, ok := shard.messages[key]
if ok && !entry.message.Expired(now) {
message := cloneEphemeralMessage(entry.message)
shard.mu.RUnlock()
return message, true, nil
}
shard.mu.RUnlock()
if !ok {
return domain.EphemeralMessage{}, false, nil
}
shard.mu.Lock()
if entry, ok = shard.messages[key]; ok && entry.message.Expired(now) {
delete(shard.messages, key)
delete(shard.random, ephemeralRandom(entry.message))
}
shard.mu.Unlock()
return domain.EphemeralMessage{}, false, nil
}
func (s *EphemeralMessageStore) EditEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) {
key := ephemeralKey(peer, id)
shard := s.shard(peer)
shard.mu.Lock()
defer shard.mu.Unlock()
entry, ok := shard.messages[key]
if !ok {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if entry.message.Expired(now) {
delete(shard.messages, key)
delete(shard.random, ephemeralRandom(entry.message))
return domain.EphemeralMessage{}, domain.ErrEphemeralExpired
}
if entry.message.Deleted {
return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted
}
if expectedVersion == 0 || entry.message.Version != expectedVersion {
return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict
}
if domain.ValidateEphemeralContent(content) != nil {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
entry.message.Content = cloneEphemeralContent(content)
entry.message.EditDate = editDate
entry.message.Version++
shard.messages[key] = entry
return cloneEphemeralMessage(entry.message), nil
}
func (s *EphemeralMessageStore) DeleteEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) {
key := ephemeralKey(peer, id)
shard := s.shard(peer)
shard.mu.Lock()
defer shard.mu.Unlock()
entry, ok := shard.messages[key]
if !ok {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
}
if entry.message.Expired(now) {
delete(shard.messages, key)
delete(shard.random, ephemeralRandom(entry.message))
return domain.EphemeralMessage{}, false, domain.ErrEphemeralExpired
}
if entry.message.Deleted {
return cloneEphemeralMessage(entry.message), false, nil
}
if expectedVersion == 0 || entry.message.Version != expectedVersion {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict
}
entry.message.Deleted = true
entry.message.Version++
// Keep a small tombstone until the original TTL. It prevents a delayed
// random-id retry from resurrecting a message after delete.
entry.message.Content = domain.EphemeralContent{}
shard.messages[key] = entry
return cloneEphemeralMessage(entry.message), true, nil
}
func (s *EphemeralMessageStore) PruneExpiredEphemeralMessages(_ context.Context, now time.Time, limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
deleted := 0
nowUnixNano := now.UnixNano()
start := int(s.messageCursor.Add(1)-1) & (ephemeralShardCount - 1)
for offset := range ephemeralShardCount {
shard := &s.shards[(start+offset)&(ephemeralShardCount-1)]
shard.mu.Lock()
for deleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano {
expiry := heap.Pop(&shard.expiry).(ephemeralExpiry)
entry, ok := shard.messages[expiry.key]
if !ok || entry.generation != expiry.generation {
continue
}
delete(shard.messages, expiry.key)
delete(shard.random, ephemeralRandom(entry.message))
deleted++
}
shard.mu.Unlock()
if deleted >= limit {
break
}
}
// Callback authorizations have an independent 15-second TTL. Give their
// heap an independent bounded budget so a hot message shard cannot starve
// callback cleanup and cause an in-memory deployment to grow forever.
callbackDeleted := 0
callbackStart := int(s.callbackCursor.Add(1)-1) & (ephemeralShardCount - 1)
for offset := range ephemeralShardCount {
shard := &s.callbackActions[(callbackStart+offset)&(ephemeralShardCount-1)]
shard.mu.Lock()
for callbackDeleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano {
expiry := heap.Pop(&shard.expiry).(ephemeralCallbackExpiry)
entry, ok := shard.actions[expiry.queryID]
if !ok || entry.generation != expiry.generation {
continue
}
delete(shard.actions, expiry.queryID)
callbackDeleted++
}
shard.mu.Unlock()
if callbackDeleted >= limit {
break
}
}
return deleted, nil
}
func (s *EphemeralMessageStore) shard(peer domain.Peer) *ephemeralShard {
// Peer IDs are already uniformly allocated monotonically; multiplicative
// mixing avoids adjacent hot groups concentrating in neighboring low bits.
index := (uint64(peer.ID) * 11400714819323198485) >> (64 - 6)
return &s.shards[index]
}
func ephemeralKey(peer domain.Peer, id int) ephemeralMessageKey {
return ephemeralMessageKey{peerType: peer.Type, peerID: peer.ID, id: id}
}
func ephemeralRandom(message domain.EphemeralMessage) ephemeralRandomKey {
return ephemeralRandomKey{
peerType: message.Peer.Type,
peerID: message.Peer.ID,
senderID: message.SenderUserID,
receiverID: message.ReceiverUserID,
randomID: message.RandomID,
}
}
func cloneEphemeralMessage(message domain.EphemeralMessage) domain.EphemeralMessage {
message.Content = cloneEphemeralContent(message.Content)
if message.BotAPIReply != nil {
reply := *message.BotAPIReply
reply.Content = cloneEphemeralContent(reply.Content)
reply.BotAPIReply = nil
message.BotAPIReply = &reply
}
return message
}
func cloneEphemeralContent(content domain.EphemeralContent) domain.EphemeralContent {
content.Entities = append([]domain.MessageEntity(nil), content.Entities...)
content.Media = cloneRequestedPeerMedia(content.Media)
content.ReplyMarkup = cloneReplyMarkup(content.ReplyMarkup)
content.RichMessage = cloneRichMessage(content.RichMessage)
return content
}

View file

@ -0,0 +1,53 @@
package memory
import (
"context"
"sync"
"telesrv/internal/domain"
)
type ephemeralReportKey struct {
reporterUserID int64
channelID int64
messageID int
option string
commentHash [32]byte
}
// EphemeralReportStore is the deterministic in-memory test implementation.
type EphemeralReportStore struct {
mu sync.Mutex
reports map[ephemeralReportKey]domain.EphemeralAbuseReport
}
func NewEphemeralReportStore() *EphemeralReportStore {
return &EphemeralReportStore{reports: make(map[ephemeralReportKey]domain.EphemeralAbuseReport)}
}
func (s *EphemeralReportStore) CreateEphemeralReport(_ context.Context, report domain.EphemeralAbuseReport) (bool, error) {
if err := report.Validate(); err != nil {
return false, err
}
key := ephemeralReportKey{
reporterUserID: report.ReporterUserID, channelID: report.Evidence.Peer.ID,
messageID: report.Evidence.MessageID, option: report.Option, commentHash: report.CommentHash,
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.reports[key]; exists {
return false, nil
}
s.reports[key] = report
return true, nil
}
func (s *EphemeralReportStore) Reports() []domain.EphemeralAbuseReport {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.EphemeralAbuseReport, 0, len(s.reports))
for _, report := range s.reports {
out = append(out, report)
}
return out
}

View file

@ -0,0 +1,184 @@
package memory
import (
"context"
"crypto/sha256"
"errors"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
)
func TestEphemeralMessageStoreCreateReplayEditDeleteAndExpiry(t *testing.T) {
ctx := context.Background()
store := NewEphemeralMessageStore()
now := time.Unix(1_800_000_000, 0)
message := testEphemeralMessage(now)
created, fresh, err := store.CreateEphemeralMessage(ctx, message)
if err != nil || !fresh || created.ID != message.ID {
t.Fatalf("create = %+v fresh=%v err=%v", created, fresh, err)
}
replayed, fresh, err := store.CreateEphemeralMessage(ctx, message)
if err != nil || fresh || replayed.Version != 1 {
t.Fatalf("replay = %+v fresh=%v err=%v", replayed, fresh, err)
}
conflict := message
conflict.ID++
conflict.PayloadHash = sha256.Sum256([]byte("different"))
if _, _, err := store.CreateEphemeralMessage(ctx, conflict); !errors.Is(err, domain.ErrEphemeralRandomIDConflict) {
t.Fatalf("random-id conflict err=%v", err)
}
edited, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, int(now.Unix())+1, now)
if err != nil || edited.Version != 2 || edited.Content.Message != "edited" {
t.Fatalf("edit = %+v err=%v", edited, err)
}
if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "stale"}, int(now.Unix())+2, now); !errors.Is(err, domain.ErrEphemeralVersionConflict) {
t.Fatalf("stale edit err=%v", err)
}
deleted, changed, err := store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now)
if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 || deleted.Content.Message != "" {
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
}
deleted, changed, err = store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 3, now)
if err != nil || changed || !deleted.Deleted {
t.Fatalf("repeat delete = %+v changed=%v err=%v", deleted, changed, err)
}
if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 3, domain.EphemeralContent{Message: "resurrect"}, int(now.Unix())+3, now); !errors.Is(err, domain.ErrEphemeralDeleted) {
t.Fatalf("edit deleted err=%v", err)
}
if _, found, err := store.GetEphemeralMessage(ctx, message.Peer, message.ID, message.ExpiresAt); err != nil || found {
t.Fatalf("expired found=%v err=%v", found, err)
}
}
func TestEphemeralMessageStoreIDCollisionAndBoundedPrune(t *testing.T) {
ctx := context.Background()
store := NewEphemeralMessageStore()
now := time.Unix(1_800_000_100, 0)
first := testEphemeralMessage(now)
if _, _, err := store.CreateEphemeralMessage(ctx, first); err != nil {
t.Fatal(err)
}
second := first
second.RandomID++
second.PayloadHash = sha256.Sum256([]byte("second"))
if _, _, err := store.CreateEphemeralMessage(ctx, second); !errors.Is(err, domain.ErrEphemeralIDCollision) {
t.Fatalf("id collision err=%v", err)
}
if got, err := store.PruneExpiredEphemeralMessages(ctx, first.ExpiresAt, 1); err != nil || got != 1 {
t.Fatalf("prune=%d err=%v", got, err)
}
}
func TestEphemeralCallbackActionExactBotAndExpiry(t *testing.T) {
ctx := context.Background()
store := NewEphemeralMessageStore()
now := time.Unix(1_800_000_000, 0)
action := domain.EphemeralCallbackAction{
QueryID: 81, BotUserID: 2001, UserID: 3001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17, TopMessageID: 42,
Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9},
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow),
}
if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created {
t.Fatalf("put created=%v err=%v", created, err)
}
if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || created {
t.Fatalf("duplicate created=%v err=%v", created, err)
}
if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID+1, action.QueryID, now); err != nil || found {
t.Fatalf("wrong bot found=%v err=%v", found, err)
}
got, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now)
if err != nil || !found || got.TopMessageID != 42 {
t.Fatalf("get=%+v found=%v err=%v", got, found, err)
}
if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, action.ExpiresAt); err != nil || found {
t.Fatalf("expired found=%v err=%v", found, err)
}
}
func TestEphemeralCallbackActionBoundedHeapPrune(t *testing.T) {
ctx := context.Background()
store := NewEphemeralMessageStore()
now := time.Unix(1_800_000_000, 0)
action := domain.EphemeralCallbackAction{
QueryID: 82, BotUserID: 2001, UserID: 3001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17,
Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9},
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow),
}
if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created {
t.Fatalf("put created=%v err=%v", created, err)
}
if _, err := store.PruneExpiredEphemeralMessages(ctx, action.ExpiresAt, 1); err != nil {
t.Fatalf("prune err=%v", err)
}
shard := &store.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)]
shard.mu.RLock()
_, found := shard.actions[action.QueryID]
shard.mu.RUnlock()
if found {
t.Fatal("expired callback action survived bounded heap prune")
}
}
func TestEphemeralReportStoreIdempotency(t *testing.T) {
store := NewEphemeralReportStore()
now := time.Unix(1_800_000_000, 0)
message := testEphemeralMessage(now)
message.ReceiverUserID = 3001
report := domain.NewEphemeralAbuseReport(message.ReceiverUserID, "spam", "evidence", message, now)
if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || !created {
t.Fatalf("create=%v err=%v", created, err)
}
if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || created {
t.Fatalf("retry create=%v err=%v", created, err)
}
reports := store.Reports()
if len(reports) != 1 || reports[0].Evidence.Content.Message != message.Content.Message {
t.Fatalf("reports=%+v", reports)
}
}
func testEphemeralMessage(now time.Time) domain.EphemeralMessage {
return domain.EphemeralMessage{
ID: 17,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001},
SenderUserID: 2001,
ReceiverUserID: 3001,
Date: int(now.Unix()),
RandomID: 99,
Content: domain.EphemeralContent{Message: "/private"},
PayloadHash: sha256.Sum256([]byte("payload")),
Version: 1,
CreatedAt: now,
ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
}
func BenchmarkEphemeralMessageStoreParallelCreate(b *testing.B) {
store := NewEphemeralMessageStore()
base := time.Unix(1_800_000_000, 0)
ctx := context.Background()
var sequence atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
n := sequence.Add(1)
message := testEphemeralMessage(base)
message.ID = int(n%1_000_000) + 1
message.Peer.ID += n
message.RandomID += n
message.PayloadHash = sha256.Sum256([]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)})
if _, _, err := store.CreateEphemeralMessage(ctx, message); err != nil {
b.Errorf("create: %v", err)
}
}
})
}

View file

@ -108,6 +108,12 @@ func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
return nil
}
clone := *media
if media.LivePhotoVideo != nil {
video := *media.LivePhotoVideo
video.FileReference = append([]byte(nil), media.LivePhotoVideo.FileReference...)
video.Attributes = append([]domain.DocumentAttribute(nil), media.LivePhotoVideo.Attributes...)
clone.LivePhotoVideo = &video
}
if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
return &clone
}