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
458
internal/store/redisstore/ephemeral.go
Normal file
458
internal/store/redisstore/ephemeral.go
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
maxEncodedEphemeralMessageBytes = 2 << 20
|
||||
ephemeralPushChannel = "telesrv:ephemeral:push:v1"
|
||||
)
|
||||
|
||||
type EphemeralMessageStore struct {
|
||||
c redis.UniversalClient
|
||||
}
|
||||
|
||||
func NewEphemeralMessageStore(c redis.UniversalClient) *EphemeralMessageStore {
|
||||
return &EphemeralMessageStore{c: c}
|
||||
}
|
||||
|
||||
func (s *EphemeralMessageStore) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error {
|
||||
if s == nil || s.c == nil {
|
||||
return errors.New("redis ephemeral push broker is not configured")
|
||||
}
|
||||
if !validEphemeralPush(event) {
|
||||
return errors.New("invalid ephemeral push")
|
||||
}
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ephemeral push: %w", err)
|
||||
}
|
||||
if len(raw) > maxEncodedEphemeralMessageBytes {
|
||||
return errors.New("ephemeral push exceeds encoded size limit")
|
||||
}
|
||||
if err := s.c.Publish(ctx, ephemeralPushChannel, raw).Err(); err != nil {
|
||||
return fmt.Errorf("redis publish ephemeral push: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EphemeralMessageStore) SubscribeEphemeralPushes(ctx context.Context, handle func(context.Context, store.EphemeralPush)) error {
|
||||
if s == nil || s.c == nil {
|
||||
return errors.New("redis ephemeral push broker is not configured")
|
||||
}
|
||||
if handle == nil {
|
||||
return errors.New("ephemeral push handler is nil")
|
||||
}
|
||||
pubsub := s.c.Subscribe(ctx, ephemeralPushChannel)
|
||||
defer func() { _ = pubsub.Close() }()
|
||||
if _, err := pubsub.Receive(ctx); err != nil {
|
||||
return fmt.Errorf("redis subscribe ephemeral push: %w", err)
|
||||
}
|
||||
messages := pubsub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case item, ok := <-messages:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if len(item.Payload) > maxEncodedEphemeralMessageBytes {
|
||||
continue
|
||||
}
|
||||
var event store.EphemeralPush
|
||||
if strictUnmarshalEphemeral([]byte(item.Payload), &event) != nil || !validEphemeralPush(event) {
|
||||
continue
|
||||
}
|
||||
handle(ctx, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validEphemeralPush(event store.EphemeralPush) bool {
|
||||
if event.SourceID == "" || event.TargetUserID <= 0 || event.Date <= 0 || event.Message.ID <= 0 ||
|
||||
event.Message.Peer.Type != domain.PeerTypeChannel || event.Message.Peer.ID <= 0 || event.Message.ValidateStored() != nil {
|
||||
return false
|
||||
}
|
||||
if event.TargetBusinessAuthKey != ([8]byte{}) &&
|
||||
(event.Message.OriginDevice.UserID != event.TargetUserID || event.Message.OriginDevice.BusinessAuthKeyID != event.TargetBusinessAuthKey) {
|
||||
return false
|
||||
}
|
||||
switch event.Kind {
|
||||
case store.EphemeralPushNew, store.EphemeralPushEdit:
|
||||
return !event.Message.Deleted && event.Callback == nil && event.TargetUserID == event.Message.ReceiverUserID
|
||||
case store.EphemeralPushDelete:
|
||||
return event.Message.Deleted && event.Callback == nil &&
|
||||
(event.TargetUserID == event.Message.SenderUserID || event.TargetUserID == event.Message.ReceiverUserID)
|
||||
case store.EphemeralPushCallback:
|
||||
return event.Callback != nil && event.Callback.BotUserID == event.TargetUserID &&
|
||||
event.Callback.ID != 0 && event.Callback.UserID == event.Message.ReceiverUserID &&
|
||||
event.Callback.ChatInstance != 0 && len(event.Callback.Data) <= domain.MaxEphemeralCallbackDataBytes && event.Callback.InlineMessage == nil &&
|
||||
event.Callback.MessageID == event.Message.ID && event.Callback.Peer == event.Message.Peer &&
|
||||
event.TargetUserID == event.Message.SenderUserID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ephemeralPeerTag(peer domain.Peer) string {
|
||||
// A shared Redis Cluster hash tag keeps the message and random-id index in
|
||||
// the same slot, so the two-key Lua transaction remains cluster-safe.
|
||||
return fmt.Sprintf("{ephemeral:%s:%d}", peer.Type, peer.ID)
|
||||
}
|
||||
|
||||
func ephemeralMessageKey(peer domain.Peer, id int) string {
|
||||
return fmt.Sprintf("telesrv:%s:message:%d", ephemeralPeerTag(peer), id)
|
||||
}
|
||||
|
||||
func ephemeralRandomKey(message domain.EphemeralMessage) string {
|
||||
return fmt.Sprintf("telesrv:%s:random:%d:%d:%d", ephemeralPeerTag(message.Peer),
|
||||
message.SenderUserID, message.ReceiverUserID, message.RandomID)
|
||||
}
|
||||
|
||||
func ephemeralCallbackActionKey(queryID int64) string {
|
||||
return fmt.Sprintf("telesrv:ephemeral:callback_action:%d", queryID)
|
||||
}
|
||||
|
||||
func (s *EphemeralMessageStore) PutEphemeralCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) {
|
||||
if s == nil || s.c == nil || 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
|
||||
}
|
||||
ttl := time.Until(action.ExpiresAt)
|
||||
if ttl <= 0 || ttl > domain.EphemeralReplyWindow {
|
||||
return false, domain.ErrEphemeralReplyExpired
|
||||
}
|
||||
raw, err := json.Marshal(action)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal ephemeral callback action: %w", err)
|
||||
}
|
||||
created, err := s.c.SetNX(ctx, ephemeralCallbackActionKey(action.QueryID), raw, ttl).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("redis put ephemeral callback action: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *EphemeralMessageStore) GetEphemeralCallbackAction(ctx context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) {
|
||||
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
|
||||
return domain.EphemeralCallbackAction{}, false, nil
|
||||
}
|
||||
key := ephemeralCallbackActionKey(queryID)
|
||||
raw, err := s.c.Get(ctx, key).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return domain.EphemeralCallbackAction{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.EphemeralCallbackAction{}, false, fmt.Errorf("redis get ephemeral callback action: %w", err)
|
||||
}
|
||||
var action domain.EphemeralCallbackAction
|
||||
if strictUnmarshalEphemeral(raw, &action) != nil || action.QueryID != queryID || action.BotUserID != botUserID ||
|
||||
action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel || action.Peer.ID <= 0 || action.MessageID <= 0 ||
|
||||
action.Device.UserID != action.UserID || !now.Before(action.ExpiresAt) {
|
||||
_ = s.c.Del(ctx, key).Err()
|
||||
return domain.EphemeralCallbackAction{}, false, nil
|
||||
}
|
||||
return action, true, nil
|
||||
}
|
||||
|
||||
var createEphemeralMessageScript = redis.NewScript(`
|
||||
local index = redis.call('GET', KEYS[2])
|
||||
if index then
|
||||
local separator = string.find(index, '\n', 1, true)
|
||||
if not separator then
|
||||
return {4, ''}
|
||||
end
|
||||
local target = string.sub(index, 1, separator - 1)
|
||||
local payload_hash = string.sub(index, separator + 1)
|
||||
local existing = redis.call('GET', target)
|
||||
if existing then
|
||||
if payload_hash ~= ARGV[2] then
|
||||
return {2, ''}
|
||||
end
|
||||
return {1, existing}
|
||||
end
|
||||
redis.call('DEL', KEYS[2])
|
||||
end
|
||||
if redis.call('EXISTS', KEYS[1]) ~= 0 then
|
||||
return {3, ''}
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
|
||||
redis.call('SET', KEYS[2], KEYS[1] .. '\n' .. ARGV[2], 'PX', ARGV[3])
|
||||
return {0, ARGV[1]}
|
||||
`)
|
||||
|
||||
func (s *EphemeralMessageStore) CreateEphemeralMessage(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
|
||||
if s == nil || s.c == nil {
|
||||
return domain.EphemeralMessage{}, false, errors.New("redis ephemeral store is not configured")
|
||||
}
|
||||
now := message.CreatedAt
|
||||
if err := message.ValidateForCreate(now); err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
ttl := message.ExpiresAt.Sub(now)
|
||||
if ttl <= 0 || ttl > domain.EphemeralMessageRetention {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
raw, err := marshalEphemeralMessage(message)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
value, err := createEphemeralMessageScript.Run(ctx, s.c, []string{
|
||||
ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message),
|
||||
}, raw, hex.EncodeToString(message.PayloadHash[:]), ttl.Milliseconds()).Result()
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis create ephemeral message: %w", err)
|
||||
}
|
||||
status, encoded, err := decodeEphemeralScriptResult(value)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
switch status {
|
||||
case 0, 1:
|
||||
stored, err := unmarshalEphemeralMessage(encoded)
|
||||
return stored, status == 0, err
|
||||
case 2:
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict
|
||||
case 3:
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
|
||||
default:
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral create index is corrupt")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EphemeralMessageStore) GetEphemeralMessage(ctx context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) {
|
||||
if s == nil || s.c == nil || peer.ID <= 0 || id <= 0 {
|
||||
return domain.EphemeralMessage{}, false, nil
|
||||
}
|
||||
raw, err := s.c.Get(ctx, ephemeralMessageKey(peer, id)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return domain.EphemeralMessage{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis get ephemeral message: %w", err)
|
||||
}
|
||||
message, err := unmarshalEphemeralMessage(raw)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if message.Peer != peer || message.ID != id {
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral message identity mismatch")
|
||||
}
|
||||
if message.Expired(now) {
|
||||
return domain.EphemeralMessage{}, false, nil
|
||||
}
|
||||
return message, true, nil
|
||||
}
|
||||
|
||||
var editEphemeralMessageScript = redis.NewScript(`
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return {0, ''}
|
||||
end
|
||||
local decoded, record = pcall(cjson.decode, raw)
|
||||
if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then
|
||||
return {4, ''}
|
||||
end
|
||||
if record.Deleted == true then
|
||||
return {2, raw}
|
||||
end
|
||||
if tonumber(record.Version) ~= tonumber(ARGV[1]) then
|
||||
return {3, raw}
|
||||
end
|
||||
if redis.call('PTTL', KEYS[1]) <= 0 then
|
||||
return {4, ''}
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
|
||||
return {1, ARGV[2]}
|
||||
`)
|
||||
|
||||
func (s *EphemeralMessageStore) EditEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) {
|
||||
current, found, err := s.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if current.Deleted {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted
|
||||
}
|
||||
if expectedVersion == 0 || current.Version != expectedVersion {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict
|
||||
}
|
||||
if domain.ValidateEphemeralContent(content) != nil {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
current.Content = content
|
||||
current.EditDate = editDate
|
||||
current.Version++
|
||||
replacement, err := marshalEphemeralMessage(current)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
value, err := editEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result()
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, fmt.Errorf("redis edit ephemeral message: %w", err)
|
||||
}
|
||||
status, encoded, err := decodeEphemeralScriptResult(value)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
switch status {
|
||||
case 1:
|
||||
return unmarshalEphemeralMessage(encoded)
|
||||
case 0:
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||
case 2:
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted
|
||||
case 3:
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict
|
||||
default:
|
||||
return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral edit record is corrupt")
|
||||
}
|
||||
}
|
||||
|
||||
var deleteEphemeralMessageScript = redis.NewScript(`
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return {0, ''}
|
||||
end
|
||||
local decoded, record = pcall(cjson.decode, raw)
|
||||
if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then
|
||||
return {4, ''}
|
||||
end
|
||||
if record.Deleted == true then
|
||||
return {2, raw}
|
||||
end
|
||||
if tonumber(record.Version) ~= tonumber(ARGV[1]) then
|
||||
return {3, raw}
|
||||
end
|
||||
if redis.call('PTTL', KEYS[1]) <= 0 then
|
||||
return {4, ''}
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
|
||||
return {1, ARGV[2]}
|
||||
`)
|
||||
|
||||
func (s *EphemeralMessageStore) DeleteEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) {
|
||||
current, found, err := s.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if current.Deleted {
|
||||
return current, false, nil
|
||||
}
|
||||
if expectedVersion == 0 || current.Version != expectedVersion {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict
|
||||
}
|
||||
current.Deleted = true
|
||||
current.Version++
|
||||
current.Content = domain.EphemeralContent{}
|
||||
replacement, err := marshalEphemeralMessage(current)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
value, err := deleteEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result()
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis delete ephemeral message: %w", err)
|
||||
}
|
||||
status, encoded, err := decodeEphemeralScriptResult(value)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
switch status {
|
||||
case 1, 2:
|
||||
message, err := unmarshalEphemeralMessage(encoded)
|
||||
return message, status == 1, err
|
||||
case 0:
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
|
||||
case 3:
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict
|
||||
default:
|
||||
return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral delete record is corrupt")
|
||||
}
|
||||
}
|
||||
|
||||
func (*EphemeralMessageStore) PruneExpiredEphemeralMessages(context.Context, time.Time, int) (int, error) {
|
||||
// Redis key expiry is the authoritative O(1) cleanup path; no key scan is
|
||||
// permitted here because SCAN cost would grow with total ephemeral volume.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func marshalEphemeralMessage(message domain.EphemeralMessage) ([]byte, error) {
|
||||
raw, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal ephemeral message: %w", err)
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes {
|
||||
return nil, domain.ErrEphemeralInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func unmarshalEphemeralMessage(raw []byte) (domain.EphemeralMessage, error) {
|
||||
if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes {
|
||||
return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message has invalid encoded size")
|
||||
}
|
||||
var message domain.EphemeralMessage
|
||||
if err := strictUnmarshalEphemeral(raw, &message); err != nil {
|
||||
return domain.EphemeralMessage{}, fmt.Errorf("decode redis ephemeral message: %w", err)
|
||||
}
|
||||
if message.ValidateStored() != nil {
|
||||
return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message violates stored invariants")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func strictUnmarshalEphemeral(raw []byte, value any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return fmt.Errorf("trailing ephemeral JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeEphemeralScriptResult(value any) (int64, []byte, error) {
|
||||
items, ok := value.([]interface{})
|
||||
if !ok || len(items) != 2 {
|
||||
return 0, nil, fmt.Errorf("redis ephemeral script returned %T", value)
|
||||
}
|
||||
status, ok := items[0].(int64)
|
||||
if !ok {
|
||||
return 0, nil, fmt.Errorf("redis ephemeral script returned invalid status %T", items[0])
|
||||
}
|
||||
var raw []byte
|
||||
switch value := items[1].(type) {
|
||||
case string:
|
||||
raw = []byte(value)
|
||||
case []byte:
|
||||
raw = append([]byte(nil), value...)
|
||||
case nil:
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("redis ephemeral script returned invalid payload %T", items[1])
|
||||
}
|
||||
return status, raw, nil
|
||||
}
|
||||
99
internal/store/redisstore/ephemeral_integration_test.go
Normal file
99
internal/store/redisstore/ephemeral_integration_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestRedisEphemeralAtomicLifecycleCallbackAndBroker(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{Addr: addr})
|
||||
defer client.Close()
|
||||
ctx := context.Background()
|
||||
storeImpl := NewEphemeralMessageStore(client)
|
||||
now := time.Now()
|
||||
seed := now.UnixNano() & 0x3fffffff
|
||||
message := domain.EphemeralMessage{
|
||||
ID: int(seed) + 1, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: seed + 2},
|
||||
SenderUserID: seed + 3, ReceiverUserID: seed + 4, Date: int(now.Unix()), RandomID: seed + 5,
|
||||
Content: domain.EphemeralContent{Message: "/private"}, PayloadHash: sha256.Sum256([]byte("payload")),
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = client.Del(context.Background(), ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message), ephemeralCallbackActionKey(seed+6)).Err()
|
||||
})
|
||||
created, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message)
|
||||
if err != nil || !fresh || created.ID != message.ID {
|
||||
t.Fatalf("create=%+v fresh=%v err=%v", created, fresh, err)
|
||||
}
|
||||
replay, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message)
|
||||
if err != nil || fresh || replay.ID != message.ID {
|
||||
t.Fatalf("replay=%+v fresh=%v err=%v", replay, fresh, err)
|
||||
}
|
||||
edited, err := storeImpl.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, message.Date+1, now)
|
||||
if err != nil || edited.Version != 2 || edited.Content.Message != "edited" {
|
||||
t.Fatalf("edit=%+v err=%v", edited, err)
|
||||
}
|
||||
deleted, changed, err := storeImpl.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now)
|
||||
if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 {
|
||||
t.Fatalf("delete=%+v changed=%v err=%v", deleted, changed, err)
|
||||
}
|
||||
|
||||
action := domain.EphemeralCallbackAction{
|
||||
QueryID: seed + 6, BotUserID: seed + 3, UserID: seed + 4, Peer: message.Peer,
|
||||
MessageID: message.ID, TopMessageID: 42,
|
||||
Device: domain.EphemeralDevice{UserID: seed + 4, BusinessAuthKeyID: [8]byte{7}, SessionID: 8},
|
||||
CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow),
|
||||
}
|
||||
if created, err := storeImpl.PutEphemeralCallbackAction(ctx, action); err != nil || !created {
|
||||
t.Fatalf("put callback created=%v err=%v", created, err)
|
||||
}
|
||||
got, found, err := storeImpl.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now)
|
||||
if err != nil || !found || got.TopMessageID != 42 || got.Device.BusinessAuthKeyID != action.Device.BusinessAuthKeyID {
|
||||
t.Fatalf("callback=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
brokerCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
received := make(chan store.EphemeralPush, 1)
|
||||
go func() {
|
||||
_ = storeImpl.SubscribeEphemeralPushes(brokerCtx, func(_ context.Context, event store.EphemeralPush) {
|
||||
select {
|
||||
case received <- event:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}()
|
||||
event := store.EphemeralPush{
|
||||
SourceID: "redis-test", Kind: store.EphemeralPushDelete,
|
||||
TargetUserID: message.ReceiverUserID, Message: deleted, Date: int(now.Unix()),
|
||||
}
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := storeImpl.PublishEphemeralPush(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if got.SourceID != event.SourceID || got.Message.ID != event.Message.ID || got.Kind != event.Kind {
|
||||
t.Fatalf("broker event=%+v", got)
|
||||
}
|
||||
return
|
||||
case <-brokerCtx.Done():
|
||||
t.Fatal("redis ephemeral broker did not deliver")
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue