Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
|
|
@ -3,29 +3,228 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore.
|
||||
type BotAPIUpdateStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
rows []domain.BotAPIUpdate
|
||||
state map[int64]int64
|
||||
byKey map[string]int64
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
rows []domain.BotAPIUpdate
|
||||
state map[int64]int64
|
||||
cursorInitialized map[int64]bool
|
||||
allowed map[int64]map[domain.BotAPIUpdateKind]struct{}
|
||||
byKey map[string]int64
|
||||
pollLeases map[int64]botAPIPollLease
|
||||
webhooks map[int64]domain.BotAPIWebhook
|
||||
webhookLeases map[int64]botAPIPollLease
|
||||
}
|
||||
|
||||
type botAPIPollLease struct {
|
||||
owner string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewBotAPIUpdateStore creates an in-memory Bot API update queue.
|
||||
func NewBotAPIUpdateStore() *BotAPIUpdateStore {
|
||||
return &BotAPIUpdateStore{
|
||||
nextID: 1,
|
||||
state: make(map[int64]int64),
|
||||
byKey: make(map[string]int64),
|
||||
nextID: 1,
|
||||
state: make(map[int64]int64),
|
||||
cursorInitialized: make(map[int64]bool),
|
||||
allowed: make(map[int64]map[domain.BotAPIUpdateKind]struct{}),
|
||||
byKey: make(map[string]int64),
|
||||
pollLeases: make(map[int64]botAPIPollLease),
|
||||
webhooks: make(map[int64]domain.BotAPIWebhook),
|
||||
webhookLeases: make(map[int64]botAPIPollLease),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
|
||||
return fmt.Errorf("invalid bot api webhook")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !config.AllowedUpdatesSet {
|
||||
config.AllowedUpdates = allowedUpdateKinds(s.allowed[config.BotUserID])
|
||||
}
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
|
||||
config.NextAttemptAt = time.Now()
|
||||
s.webhooks[config.BotUserID] = config
|
||||
if config.AllowedUpdates == nil {
|
||||
delete(s.allowed, config.BotUserID)
|
||||
} else {
|
||||
allowed := make(map[domain.BotAPIUpdateKind]struct{}, len(config.AllowedUpdates))
|
||||
for _, kind := range config.AllowedUpdates {
|
||||
allowed[kind] = struct{}{}
|
||||
}
|
||||
s.allowed[config.BotUserID] = allowed
|
||||
}
|
||||
if dropPending {
|
||||
s.dropPendingLocked(config.BotUserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowedUpdateKinds(items map[domain.BotAPIUpdateKind]struct{}) []domain.BotAPIUpdateKind {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.BotAPIUpdateKind, 0, len(items))
|
||||
for kind := range items {
|
||||
out = append(out, kind)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(_ context.Context, botUserID int64, dropPending bool) error {
|
||||
s.mu.Lock()
|
||||
delete(s.webhooks, botUserID)
|
||||
delete(s.webhookLeases, botUserID)
|
||||
if dropPending {
|
||||
s.dropPendingLocked(botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) BotAPIWebhook(_ context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
s.mu.RLock()
|
||||
config, found := s.webhooks[botUserID]
|
||||
s.mu.RUnlock()
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
return config, found, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(_ context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
out := make([]domain.BotAPIWebhook, 0, min(limit, len(s.webhooks)))
|
||||
for botID, config := range s.webhooks {
|
||||
lease := s.webhookLeases[botID]
|
||||
if config.NextAttemptAt.After(now) || (lease.owner != "" && lease.expiresAt.After(now)) {
|
||||
continue
|
||||
}
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
out = append(out, config)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api webhook lease")
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, found := s.webhooks[botUserID]; !found {
|
||||
return false, nil
|
||||
}
|
||||
current := s.webhookLeases[botUserID]
|
||||
if current.owner != "" && current.owner != owner && current.expiresAt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
s.webhookLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(_ context.Context, botUserID int64, owner string) error {
|
||||
s.mu.Lock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner == owner {
|
||||
delete(s.webhookLeases, botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(_ context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner != owner {
|
||||
return nil
|
||||
}
|
||||
config, found := s.webhooks[botUserID]
|
||||
if !found {
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
config.FailureCount++
|
||||
config.LastErrorDate = int(time.Now().Unix())
|
||||
if len(message) > 512 {
|
||||
message = message[:512]
|
||||
}
|
||||
config.LastErrorMessage = message
|
||||
config.NextAttemptAt = nextAttempt
|
||||
s.webhooks[botUserID] = config
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(_ context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner != owner {
|
||||
return nil
|
||||
}
|
||||
if config, found := s.webhooks[botUserID]; found {
|
||||
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
|
||||
config.NextAttemptAt = nextAttempt
|
||||
s.webhooks[botUserID] = config
|
||||
}
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) dropPendingLocked(botUserID int64) {
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
|
||||
s.state[botUserID] = row.ID
|
||||
}
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api poll lease")
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, found := s.pollLeases[botUserID]
|
||||
if found && current.owner != owner && current.expiresAt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
s.pollLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(_ context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if current, found := s.pollLeases[botUserID]; found && current.owner == owner {
|
||||
delete(s.pollLeases, botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
|
||||
if err := validateBotAPIUpdateRequest(req); err != nil {
|
||||
return domain.BotAPIUpdate{}, false, err
|
||||
|
|
@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
|
|||
key := botAPIUpdateKey(req)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if allowed, configured := s.allowed[req.BotUserID]; configured {
|
||||
if _, ok := allowed[req.Kind]; !ok {
|
||||
return domain.BotAPIUpdate{}, false, nil
|
||||
}
|
||||
}
|
||||
if existingID, ok := s.byKey[key]; ok {
|
||||
for _, row := range s.rows {
|
||||
if row.ID == existingID {
|
||||
|
|
@ -48,13 +252,57 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
|
|||
MessageID: req.MessageID,
|
||||
SourcePts: req.SourcePts,
|
||||
Date: req.Date,
|
||||
Callback: cloneBotAPICallback(req.Callback),
|
||||
Ephemeral: cloneBotAPIEphemeral(req.Ephemeral),
|
||||
}
|
||||
s.nextID++
|
||||
s.rows = append(s.rows, row)
|
||||
s.byKey[key] = row.ID
|
||||
if config, found := s.webhooks[req.BotUserID]; found {
|
||||
config.NextAttemptAt = time.Now()
|
||||
s.webhooks[req.BotUserID] = config
|
||||
}
|
||||
return cloneBotAPIUpdate(row), true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(_ context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 || tail <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
confirmed := s.state[botUserID]
|
||||
matching := make([]domain.BotAPIUpdate, 0, min(tail, limit))
|
||||
start := 0
|
||||
count := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > confirmed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > tail {
|
||||
start = count - tail
|
||||
}
|
||||
seen := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID != botUserID || row.ID <= confirmed {
|
||||
continue
|
||||
}
|
||||
if seen < start {
|
||||
seen++
|
||||
continue
|
||||
}
|
||||
matching = append(matching, cloneBotAPIUpdate(row))
|
||||
if len(matching) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return matching, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -85,13 +333,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c
|
|||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
maxExisting := int64(0)
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > maxExisting {
|
||||
maxExisting = row.ID
|
||||
}
|
||||
}
|
||||
if confirmedUpdateID > maxExisting && s.cursorInitialized[botUserID] {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if confirmedUpdateID > maxExisting {
|
||||
confirmedUpdateID = maxExisting
|
||||
}
|
||||
if confirmedUpdateID > s.state[botUserID] {
|
||||
s.state[botUserID] = confirmedUpdateID
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(_ context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if len(allowed) == 0 {
|
||||
delete(s.allowed, botUserID)
|
||||
} else {
|
||||
set := make(map[domain.BotAPIUpdateKind]struct{}, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
if kind != "" {
|
||||
set[kind] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.allowed[botUserID] = set
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
|
||||
s.state[botUserID] = row.ID
|
||||
}
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(_ context.Context, botUserID int64) (int, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
confirmed := s.state[botUserID]
|
||||
count := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > confirmed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, false, nil
|
||||
|
|
@ -103,27 +416,87 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID
|
|||
}
|
||||
|
||||
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
||||
if req.BotUserID == 0 || req.MessageID <= 0 {
|
||||
if req.BotUserID == 0 {
|
||||
return fmt.Errorf("invalid bot api update")
|
||||
}
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
|
||||
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
|
||||
}
|
||||
switch req.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if req.Peer.ID <= 0 {
|
||||
if req.Peer.ID <= 0 || req.MessageID <= 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
case "":
|
||||
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
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 ||
|
||||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
|
||||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api callback query")
|
||||
}
|
||||
inline := cb.InlineMessage
|
||||
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
|
||||
return fmt.Errorf("invalid bot api inline callback query")
|
||||
}
|
||||
if req.MessageID > 0 && inline != nil {
|
||||
return fmt.Errorf("ambiguous bot api callback query")
|
||||
}
|
||||
} else if req.Callback != nil {
|
||||
return fmt.Errorf("unexpected bot api callback query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
out := *in
|
||||
out.Data = append([]byte(nil), in.Data...)
|
||||
if in.InlineMessage != nil {
|
||||
inline := *in.InlineMessage
|
||||
out.InlineMessage = &inline
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
|
|
|||
245
internal/store/memory/botapi_update_test.go
Normal file
245
internal/store/memory/botapi_update_test.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func botAPIMessageRequest(botID int64, kind domain.BotAPIUpdateKind, messageID int) domain.EnqueueBotAPIUpdateRequest {
|
||||
return domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botID,
|
||||
Kind: kind,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
MessageID: messageID,
|
||||
SourcePts: messageID,
|
||||
Date: 1700000000 + messageID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollLeaseCompareOwnerAndExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "one", 20*time.Millisecond); err != nil || !acquired {
|
||||
t.Fatalf("first acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || acquired {
|
||||
t.Fatalf("competing acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := store.ReleaseBotAPIPollLease(ctx, 1001, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); acquired {
|
||||
t.Fatal("stale release removed active owner")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || !acquired {
|
||||
t.Fatalf("expired acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIWebhookLeaseWakeAndAtomicDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || !created {
|
||||
t.Fatalf("enqueue initial created=%v err=%v", created, err)
|
||||
}
|
||||
config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/hook", MaxConnections: 8}
|
||||
if err := store.SetBotAPIWebhook(ctx, config, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, _ := store.PendingBotAPIUpdateCount(ctx, 1001); count != 0 {
|
||||
t.Fatalf("pending after atomic drop=%d", count)
|
||||
}
|
||||
if acquired, err := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-1", time.Second); err != nil || !acquired {
|
||||
t.Fatalf("lease acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, _ := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-2", time.Second); acquired {
|
||||
t.Fatal("second webhook worker acquired active lease")
|
||||
}
|
||||
if err := store.RecordBotAPIWebhookSuccess(ctx, 1001, "worker-1", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
|
||||
t.Fatalf("idle due=%#v err=%v", due, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created {
|
||||
t.Fatalf("enqueue wake created=%v err=%v", created, err)
|
||||
}
|
||||
if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != 1001 {
|
||||
t.Fatalf("woken due=%#v err=%v", due, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIWebhookAllowedUpdatesOmissionPreservesPolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/one", MaxConnections: 8}
|
||||
if err := store.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err := store.BotAPIWebhook(ctx, 1001)
|
||||
if err != nil || !found || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
|
||||
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || created || row.ID != 0 {
|
||||
t.Fatalf("message bypassed preserved policy: row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
config.URL = "https://example.test/two"
|
||||
config.AllowedUpdatesSet = true // Explicit empty resets to the default/all policy.
|
||||
if err := store.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err = store.BotAPIWebhook(ctx, 1001)
|
||||
if err != nil || !found || stored.AllowedUpdates != nil {
|
||||
t.Fatalf("explicit empty webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created {
|
||||
t.Fatalf("message after explicit reset created=%v err=%v", created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIUpdateCursorClampDropAndTail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
for id := 1; id <= 5; id++ {
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, id)); err != nil || !created {
|
||||
t.Fatalf("enqueue %d: created=%v err=%v", id, created, err)
|
||||
}
|
||||
}
|
||||
tail, err := store.ListTailBotAPIUpdates(ctx, 1001, 2, 100)
|
||||
if err != nil || len(tail) != 2 || tail[0].MessageID != 4 || tail[1].MessageID != 5 {
|
||||
t.Fatalf("tail = %#v err=%v", tail, err)
|
||||
}
|
||||
if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil {
|
||||
t.Fatalf("confirm huge offset: %v", err)
|
||||
}
|
||||
confirmed, found, err := store.ConfirmedBotAPIUpdateID(ctx, 1001)
|
||||
if err != nil || !found || confirmed != 5 {
|
||||
t.Fatalf("confirmed = %d found=%v err=%v, want 5", confirmed, found, err)
|
||||
}
|
||||
row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 6))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue after huge offset: row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil {
|
||||
t.Fatalf("repeat foreign offset: %v", err)
|
||||
}
|
||||
if confirmed, _, _ := store.ConfirmedBotAPIUpdateID(ctx, 1001); confirmed != 5 {
|
||||
t.Fatalf("repeat foreign offset advanced cursor to %d, want 5", confirmed)
|
||||
}
|
||||
pending, err := store.ListBotAPIUpdates(ctx, 1001, confirmed+1, 100)
|
||||
if err != nil || len(pending) != 1 || pending[0].MessageID != 6 {
|
||||
t.Fatalf("pending after huge offset = %#v err=%v", pending, err)
|
||||
}
|
||||
if err := store.DropPendingBotAPIUpdates(ctx, 1001); err != nil {
|
||||
t.Fatalf("drop pending: %v", err)
|
||||
}
|
||||
count, err := store.PendingBotAPIUpdateCount(ctx, 1001)
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("pending count = %d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIAllowedUpdatesOnlyAffectsFutureEnqueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue pre-policy: %#v created=%v err=%v", first, created, err)
|
||||
}
|
||||
if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
|
||||
t.Fatalf("set policy: %v", err)
|
||||
}
|
||||
if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || created || row.ID != 0 {
|
||||
t.Fatalf("filtered message = %#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateEditedMessage, 3)); err != nil || !created {
|
||||
t.Fatalf("allowed edit created=%v err=%v", created, err)
|
||||
}
|
||||
rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100)
|
||||
if err != nil || len(rows) != 2 || rows[0].ID != first.ID || rows[1].Kind != domain.BotAPIUpdateEditedMessage {
|
||||
t.Fatalf("rows = %#v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, Data: []byte("tap"),
|
||||
InlineMessage: &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 15, AccessHash: 1234},
|
||||
}
|
||||
row, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
|
||||
})
|
||||
if err != nil || !created || row.MessageID != 0 || row.Peer != (domain.Peer{}) || row.Callback == nil ||
|
||||
row.Callback.InlineMessage == nil || *row.Callback.InlineMessage != *callback.InlineMessage {
|
||||
t.Fatalf("inline callback row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
callback.Data[0] = 'X'
|
||||
callback.InlineMessage.ID = 99
|
||||
rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100)
|
||||
if err != nil || len(rows) != 1 || string(rows[0].Callback.Data) != "tap" || rows[0].Callback.InlineMessage.ID != 15 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -290,6 +290,12 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af
|
|||
}
|
||||
out = append(out, channelID)
|
||||
}
|
||||
for channelID, channel := range s.channels {
|
||||
if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) || containsInt64(out, channelID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, channelID)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
|
|
@ -324,6 +330,24 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
|
|||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
for channelID, channel := range s.channels {
|
||||
if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) {
|
||||
continue
|
||||
}
|
||||
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
|
||||
if checkpoint.LatestEventDate > sinceDate {
|
||||
found := false
|
||||
for _, item := range out {
|
||||
if item.ChannelID == channelID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
|
|
@ -331,6 +355,34 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID int64) bool {
|
||||
if userID == 0 || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
|
||||
return false
|
||||
}
|
||||
parent, ok := s.channels[mono.LinkedMonoforumID]
|
||||
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID {
|
||||
return false
|
||||
}
|
||||
if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
return true
|
||||
}
|
||||
for _, msg := range s.messages[mono.ID] {
|
||||
if !msg.Deleted && msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsInt64(items []int64, target int64) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelIDLocked() int64 {
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
|
|
@ -369,6 +421,10 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
|
|||
if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
|
||||
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil
|
||||
}
|
||||
parent, ok := s.channels[channel.LinkedMonoforumID]
|
||||
if ok && !parent.Deleted && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID {
|
||||
return channel, syntheticMonoforumUserMember(channel, userID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUser
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
inviter := s.members[channelID][inviterUserID]
|
||||
if !canInviteToChannel(channel, inviter) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, d
|
|||
if !ok || channel.Deleted {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
preJoinTopID := channel.TopMessageID
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
|
|
@ -652,6 +655,31 @@ func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64
|
|||
return append([]domain.Channel(nil), out...), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListCommunityLinkableChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Channel, 0)
|
||||
for channelID, members := range s.members {
|
||||
member := members[userID]
|
||||
if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || channel.Monoforum || channel.LinkedCommunityID != 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, channel)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > domain.MaxCommunityPeers {
|
||||
out = out[:domain.MaxCommunityPeers]
|
||||
}
|
||||
return append([]domain.Channel(nil), out...), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -1050,6 +1078,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
|
|||
return member
|
||||
}
|
||||
|
||||
func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: mono.ID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
|
||||
func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) {
|
||||
if !publicSearchableChannel(channel) {
|
||||
return 0, false
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
|
|||
in.Discussion = cloneChannelDiscussionRef(in.Discussion)
|
||||
in.Replies = cloneChannelMessageReplies(in.Replies)
|
||||
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)
|
||||
if in.SuggestedPost != nil {
|
||||
suggested := *in.SuggestedPost
|
||||
if suggested.Price != nil {
|
||||
price := *suggested.Price
|
||||
suggested.Price = &price
|
||||
}
|
||||
in.SuggestedPost = &suggested
|
||||
}
|
||||
if in.SendAs != nil {
|
||||
p := *in.SendAs
|
||||
in.SendAs = &p
|
||||
|
|
|
|||
|
|
@ -24,12 +24,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
|
|||
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
|
||||
query := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
matched := make([]domain.ChannelMessage, 0, len(items))
|
||||
monoforumUserView := channel.Monoforum && !isChannelAdmin(member)
|
||||
for _, msg := range items {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
if channel.Monoforum {
|
||||
if monoforumUserView && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
if !monoforumUserView && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
|
|
@ -186,7 +192,16 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hits := make([]hit, 0, req.Limit+1)
|
||||
channelIDs := make(map[int64]struct{}, len(req.ChannelIDs))
|
||||
for _, id := range req.ChannelIDs {
|
||||
channelIDs[id] = struct{}{}
|
||||
}
|
||||
for channelID, channel := range s.channels {
|
||||
if req.RestrictChannelIDs {
|
||||
if _, ok := channelIDs[channelID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if channel.Deleted {
|
||||
continue
|
||||
}
|
||||
|
|
@ -197,7 +212,10 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
|
|||
continue
|
||||
}
|
||||
member, ok := s.members[channelID][viewerUserID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages {
|
||||
joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages
|
||||
publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) &&
|
||||
(!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages)
|
||||
if !joined && !publicPreview {
|
||||
continue
|
||||
}
|
||||
if req.HasFolderID {
|
||||
|
|
@ -213,7 +231,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
|
|||
if query == "" && !req.MusicOnly || query != "" && strings.TrimSpace(msg.Body) == "" {
|
||||
continue
|
||||
}
|
||||
if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
|
||||
if joined && member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if req.MinDate > 0 && msg.Date <= req.MinDate {
|
||||
|
|
|
|||
|
|
@ -316,13 +316,21 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla
|
|||
Message: cloneChannelMessage(replay),
|
||||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
return domain.SendChannelMessageResult{
|
||||
result := domain.SendChannelMessageResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Message: cloneChannelMessage(replay),
|
||||
Event: event,
|
||||
Duplicate: true,
|
||||
ReplayDeleteEvent: replayDelete,
|
||||
}, true, nil
|
||||
}
|
||||
if first.PaidMessageStars > 0 {
|
||||
balance, ok := s.starsBalances[first.SenderUserID]
|
||||
if !ok {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory paid-message replay has no sender balance")
|
||||
}
|
||||
result.SenderStarsBalance = &domain.StarsBalance{UserID: first.SenderUserID, Balance: balance, Granted: true}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,19 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const paidMessageChannelCommissionPermille int64 = 850
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。
|
||||
// 与 postgres 行为一致:复用 channel pts/事件;订阅者无需成员记录且只能写自己的 saved_peer,
|
||||
// 母频道管理员可以回复任意订阅者。
|
||||
func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
var fingerprint []byte
|
||||
var err error
|
||||
if req.RandomID != 0 {
|
||||
|
|
@ -43,23 +49,81 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
if !ok || channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
parent, ok := s.channels[channel.LinkedMonoforumID]
|
||||
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != channel.ID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
|
||||
}
|
||||
parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID]
|
||||
isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if req.ReplyTo != nil {
|
||||
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range s.messages[channel.ID] {
|
||||
if candidate.ID == req.ReplyTo.MessageID && !candidate.Deleted && candidate.SavedPeer == req.SavedPeer {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
paidMessageStars := int64(0)
|
||||
balanceAfter := int64(0)
|
||||
if !isAdmin && channel.SendPaidMessagesStars > 0 {
|
||||
if channel.SendPaidMessagesStars != parent.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
|
||||
}
|
||||
current, ok := s.starsBalances[req.SenderUserID]
|
||||
if !ok {
|
||||
current = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
if current < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
paidMessageStars = channel.SendPaidMessagesStars
|
||||
balanceAfter = current - paidMessageStars
|
||||
senderBalance = &domain.StarsBalance{UserID: req.SenderUserID, Balance: balanceAfter, Granted: true}
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
if isAdmin {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
pts := s.nextChannelPtsLocked(req.MonoforumID)
|
||||
msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
SavedPeer: req.SavedPeer,
|
||||
Date: req.Date,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: from,
|
||||
SavedPeer: req.SavedPeer,
|
||||
SuggestedPost: req.SuggestedPost,
|
||||
PaidMessageStars: paidMessageStars,
|
||||
Date: req.Date,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Pts: pts,
|
||||
}
|
||||
// Store owns the persisted snapshot; callers must not be able to mutate it through
|
||||
// SuggestedPost/Media pointers after SendMonoforumMessage returns.
|
||||
msg = cloneChannelMessage(msg)
|
||||
var sendSnapshot []byte
|
||||
if req.RandomID != 0 {
|
||||
var snapshotErr error
|
||||
|
|
@ -78,6 +142,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
SenderUserID: req.SenderUserID,
|
||||
}
|
||||
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
|
||||
if paidMessageStars > 0 {
|
||||
s.starsBalances[req.SenderUserID] = balanceAfter
|
||||
s.channelStarsBalances[parent.ID] += paidMessageStars * paidMessageChannelCommissionPermille / 1000
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
|
||||
s.sendSnapshots[replayKey] = sendSnapshot
|
||||
|
|
@ -87,7 +155,13 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
s.channels[req.MonoforumID] = channel
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, nil
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
for userID, member := range s.members[parent.ID] {
|
||||
if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
recipients = append(recipients, userID)
|
||||
}
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
}
|
||||
|
||||
// findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。
|
||||
|
|
|
|||
|
|
@ -35,11 +35,14 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
|
||||
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
|
||||
}
|
||||
if !containsInt64(m1.Recipients, 1) || !containsInt64(m1.Recipients, 42) || len(m1.Recipients) != 2 {
|
||||
t.Fatalf("m1 recipients = %v, want subscriber 42 + parent admin 1", m1.Recipients)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil {
|
||||
t.Fatalf("subscriber send 2: %v", err)
|
||||
}
|
||||
// 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil {
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_003}); err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -58,8 +61,17 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
|
||||
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
|
||||
}
|
||||
if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
|
||||
t.Fatalf("subscriber main monoforum history = nil err, want denied")
|
||||
subscriberHist, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber monoforum history: %v", err)
|
||||
}
|
||||
if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 {
|
||||
t.Fatalf("subscriber monoforum history count=%d len=%d, want 3 own messages", subscriberHist.Count, len(subscriberHist.Messages))
|
||||
}
|
||||
for _, message := range subscriberHist.Messages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber history leaked saved_peer=%+v, want self %+v", message.SavedPeer, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:相同 randomID 返回原消息、不重复。
|
||||
|
|
@ -84,6 +96,12 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if hist.Messages[0].Body != "reply" {
|
||||
t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body)
|
||||
}
|
||||
if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID {
|
||||
t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
for _, m := range hist.Messages {
|
||||
if m.SavedPeer != sub {
|
||||
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
|
||||
|
|
@ -99,6 +117,40 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if subHist.Count != 3 {
|
||||
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
|
||||
}
|
||||
subscriberChannelHistory, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel history after other subscriber: %v", err)
|
||||
}
|
||||
if subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 {
|
||||
t.Fatalf("subscriber channel history after other = %d/%d, want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages))
|
||||
}
|
||||
for _, message := range subscriberChannelHistory.Messages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber channel history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel difference: %v", err)
|
||||
}
|
||||
if diff.Pts != store.channels[monoID].Pts {
|
||||
t.Fatalf("subscriber difference pts = %d, want channel pts %d despite filtered events", diff.Pts, store.channels[monoID].Pts)
|
||||
}
|
||||
if len(diff.NewMessages) != 3 {
|
||||
t.Fatalf("subscriber difference messages = %d, want own 3", len(diff.NewMessages))
|
||||
}
|
||||
for _, message := range diff.NewMessages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber difference leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
activeChannelIDs, err := store.ListActiveChannelIDsForUser(ctx, 42, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber active channels: %v", err)
|
||||
}
|
||||
if !containsInt64(activeChannelIDs, monoID) {
|
||||
t.Fatalf("subscriber active channels = %v, want monoforum %d for offline recovery", activeChannelIDs, monoID)
|
||||
}
|
||||
|
||||
// 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。
|
||||
a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010})
|
||||
|
|
@ -141,6 +193,13 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("delete monoforum message: %v", err)
|
||||
}
|
||||
deleteDiff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber difference after own delete: %v", err)
|
||||
}
|
||||
if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID {
|
||||
t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts)
|
||||
}
|
||||
ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID])
|
||||
deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014})
|
||||
if err != nil {
|
||||
|
|
@ -153,3 +212,73 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPaidMonoforumMessageLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Paid DM", Broadcast: true, Date: 1_700_002_000})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable paid DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
baseMessages := len(store.messages[monoID])
|
||||
|
||||
low := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3001, Message: "too low", AllowPaidStars: 9, Date: 1_700_002_001}
|
||||
var required *domain.StarsPaymentRequiredError
|
||||
if _, err := store.SendMonoforumMessage(ctx, low); !errors.As(err, &required) || required.Stars != 10 {
|
||||
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
|
||||
}
|
||||
if len(store.messages[monoID]) != baseMessages {
|
||||
t.Fatalf("low authorization wrote a message")
|
||||
}
|
||||
|
||||
store.starsBalances[42] = 25
|
||||
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3002, Message: "paid", AllowPaidStars: 99, Date: 1_700_002_002}
|
||||
paid, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid send: %v", err)
|
||||
}
|
||||
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("ledger sender/channel = %d/%d, want 15/8", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
duplicate, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid replay: %v", err)
|
||||
}
|
||||
if !duplicate.Duplicate || duplicate.Message.ID != paid.Message.ID || duplicate.SenderStarsBalance == nil || duplicate.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid replay = %+v, want original message and balance 15", duplicate)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("paid replay double charged: sender/channel=%d/%d", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
admin, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 3003, Message: "free admin reply", AllowPaidStars: 100, Date: 1_700_002_003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("admin reply charged: message=%+v balance=%+v channel=%d", admin.Message, admin.SenderStarsBalance, store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
store.starsBalances[99] = 5
|
||||
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 3004, Message: "insufficient", AllowPaidStars: 10, Date: 1_700_002_004,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
if store.starsBalances[99] != 5 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("insufficient send mutated ledger: sender/channel=%d/%d", store.starsBalances[99], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,27 +73,29 @@ type ChannelStore struct {
|
|||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
starsBalances map[int64]int64
|
||||
channelStarsBalances map[int64]int64
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
|
||||
topicReads map[int64]map[int64]map[int]memoryTopicRead
|
||||
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
|
||||
|
|
@ -108,35 +110,37 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
|||
// NewChannelStore creates an in-memory ChannelStore.
|
||||
func NewChannelStore() *ChannelStore {
|
||||
return &ChannelStore{
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
starsBalances: make(map[int64]int64),
|
||||
channelStarsBalances: make(map[int64]int64),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
|
|
@ -68,6 +71,16 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
}
|
||||
events := make([]domain.ChannelUpdateEvent, 0, limit)
|
||||
lastPts := req.Pts
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
visibleMonoforumMessageIDs = make(map[int]struct{})
|
||||
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
|
||||
for _, message := range s.messages[req.ChannelID] {
|
||||
if message.SavedPeer == savedPeer {
|
||||
visibleMonoforumMessageIDs[message.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, event := range s.events[req.ChannelID] {
|
||||
if event.Pts <= req.Pts {
|
||||
continue
|
||||
|
|
@ -77,6 +90,12 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if preview && visible.Type == domain.ChannelUpdateParticipant {
|
||||
continue
|
||||
}
|
||||
|
|
@ -121,6 +140,27 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
return diff, nil
|
||||
}
|
||||
|
||||
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
|
||||
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
if event.Message.ID != 0 {
|
||||
return event, event.Message.SavedPeer == savedPeer
|
||||
}
|
||||
if len(event.MessageIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
visibleIDs := make([]int, 0, len(event.MessageIDs))
|
||||
for _, id := range event.MessageIDs {
|
||||
if _, ok := visibleMessageIDs[id]; ok {
|
||||
visibleIDs = append(visibleIDs, id)
|
||||
}
|
||||
}
|
||||
if len(visibleIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
event.MessageIDs = visibleIDs
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
|
|||
1112
internal/store/memory/community.go
Normal file
1112
internal/store/memory/community.go
Normal file
File diff suppressed because it is too large
Load diff
285
internal/store/memory/community_test.go
Normal file
285
internal/store/memory/community_test.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func mustCommunityTestUser(t *testing.T, store *UserStore, firstName, phone string) domain.User {
|
||||
t.Helper()
|
||||
user, err := store.Create(context.Background(), domain.User{AccessHash: int64(len(phone) + len(firstName)), Phone: phone, FirstName: firstName})
|
||||
if err != nil {
|
||||
t.Fatalf("create user %q: %v", firstName, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func mustCommunityTestChannel(t *testing.T, store *ChannelStore, creator domain.User, title string, members ...domain.User) domain.Channel {
|
||||
t.Helper()
|
||||
memberIDs := make([]int64, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberIDs = append(memberIDs, member.ID)
|
||||
}
|
||||
created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{
|
||||
CreatorUserID: creator.ID,
|
||||
Title: title,
|
||||
Megagroup: true,
|
||||
MemberUserIDs: memberIDs,
|
||||
Date: 1_800_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel %q: %v", title, err)
|
||||
}
|
||||
return created.Channel
|
||||
}
|
||||
|
||||
func TestCommunityLifecycleRequestsSearchAndModeration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := NewUserStore()
|
||||
owner := mustCommunityTestUser(t, users, "Community Owner", "15551000001")
|
||||
member := mustCommunityTestUser(t, users, "Alice Searchable", "15551000002")
|
||||
channels := NewChannelStore()
|
||||
initial := mustCommunityTestChannel(t, channels, owner, "Initial", member)
|
||||
store := NewCommunityStore(users, channels, nil, nil)
|
||||
|
||||
created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Engineering",
|
||||
InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID},
|
||||
Visibility: domain.CommunityPeerHidden,
|
||||
Date: 1_800_000_001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create community: %v", err)
|
||||
}
|
||||
if len(created.Links) != 1 || created.Links[0].Visibility != domain.CommunityPeerHidden {
|
||||
t.Fatalf("initial links = %+v, want one hidden link", created.Links)
|
||||
}
|
||||
if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Message.Action == nil ||
|
||||
created.ServiceMessages[0].Message.Action.Type != domain.ChannelActionChangeCommunity ||
|
||||
created.ServiceMessages[0].Message.Action.CommunityID != created.Community.ID || created.ServiceMessages[0].Event.Pts == 0 {
|
||||
t.Fatalf("create service messages = %+v, want durable change-community action", created.ServiceMessages)
|
||||
}
|
||||
initialView, err := channels.GetChannel(ctx, owner.ID, initial.ID)
|
||||
if err != nil || initialView.Channel.LinkedCommunityID != created.Community.ID {
|
||||
t.Fatalf("initial linked community = %d, err=%v, want %d", initialView.Channel.LinkedCommunityID, err, created.Community.ID)
|
||||
}
|
||||
|
||||
_, err = store.CreateCommunity(ctx, domain.CreateCommunityRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Duplicate",
|
||||
InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_000_002,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrCommunityPeerLinked) {
|
||||
t.Fatalf("reuse linked peer error = %v, want ErrCommunityPeerLinked", err)
|
||||
}
|
||||
|
||||
owned := mustCommunityTestChannel(t, channels, member, "Member Owned")
|
||||
requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{
|
||||
ActorUserID: member.ID,
|
||||
CommunityID: created.Community.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_000_003,
|
||||
})
|
||||
if err != nil || !requested.RequestCreated {
|
||||
t.Fatalf("link request = %+v, err=%v, want pending request", requested, err)
|
||||
}
|
||||
page, err := store.ListCommunityPeerLinkRequests(ctx, owner.ID, created.Community.ID, "", 20)
|
||||
if err != nil || page.TotalCount != 1 || len(page.Requests) != 1 || page.Requests[0].RequestedBy != member.ID {
|
||||
t.Fatalf("request page = %+v, err=%v", page, err)
|
||||
}
|
||||
approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, created.Community.ID, requested.Peer, false, 1_800_000_004)
|
||||
if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil {
|
||||
t.Fatalf("approved request = %+v, err=%v", approved, err)
|
||||
}
|
||||
ownerView, err := store.GetCommunity(ctx, owner.ID, created.Community.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("owner community view after approval: %v", err)
|
||||
}
|
||||
for _, link := range ownerView.Links {
|
||||
if link.Peer == approved.Peer && link.CanViewHistory {
|
||||
t.Fatalf("community admin without private-channel membership advertised can_view_history")
|
||||
}
|
||||
}
|
||||
privateVisible := mustCommunityTestChannel(t, channels, owner, "Private Visible")
|
||||
linked, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{
|
||||
ActorUserID: owner.ID,
|
||||
CommunityID: created.Community.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: privateVisible.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_000_004,
|
||||
})
|
||||
if err != nil || linked.Link == nil {
|
||||
t.Fatalf("link private visible channel = %+v, err=%v", linked, err)
|
||||
}
|
||||
memberView, err := store.GetCommunity(ctx, member.ID, created.Community.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("member community view: %v", err)
|
||||
}
|
||||
foundPrivate := false
|
||||
for _, link := range memberView.Links {
|
||||
if link.Peer.ID == privateVisible.ID {
|
||||
foundPrivate = true
|
||||
if link.CanViewHistory {
|
||||
t.Fatalf("private visible channel advertised can_view_history to non-member")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundPrivate {
|
||||
t.Fatalf("visible private channel missing from member community view")
|
||||
}
|
||||
|
||||
_, err = store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{
|
||||
ActorUserID: owner.ID,
|
||||
CommunityID: created.Community.ID,
|
||||
Peer: approved.Peer,
|
||||
Visibility: domain.CommunityPeerHidden,
|
||||
Date: 1_800_000_005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrCommunityPeerLinked) {
|
||||
t.Fatalf("change visibility in place error = %v, want unlink/relink requirement", err)
|
||||
}
|
||||
|
||||
participants, err := store.ListCommunityParticipants(ctx, owner.ID, created.Community.ID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsSearch,
|
||||
Query: "searchABLE",
|
||||
}, 0, 20)
|
||||
if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID {
|
||||
t.Fatalf("participant name search = %+v, err=%v, want member", participants, err)
|
||||
}
|
||||
admins, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsAdmins,
|
||||
}, 0, 100)
|
||||
if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID {
|
||||
t.Fatalf("member-visible Community admins = %+v, err=%v, want creator", admins, err)
|
||||
}
|
||||
if _, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsKicked,
|
||||
}, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) {
|
||||
t.Fatalf("member Community kicked list error = %v, want admin required", err)
|
||||
}
|
||||
outsider := mustCommunityTestUser(t, users, "Community Outsider", "15551000003")
|
||||
if _, err := store.ListCommunityParticipants(ctx, outsider.ID, created.Community.ID, domain.ChannelParticipantsFilter{
|
||||
Kind: domain.ChannelParticipantsAdmins,
|
||||
}, 0, 100); !errors.Is(err, domain.ErrCommunityPrivate) {
|
||||
t.Fatalf("outsider Community admins error = %v, want private", err)
|
||||
}
|
||||
|
||||
ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_006)
|
||||
if err != nil {
|
||||
t.Fatalf("ban community participant: %v", err)
|
||||
}
|
||||
if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 || ban.RemovedLinks[0].Peer.ID != owned.ID {
|
||||
t.Fatalf("ban result = %+v, want one channel ban and owned-link removal", ban)
|
||||
}
|
||||
if action := ban.RemovedLinks[0].ServiceMessage.Message.Action; action == nil || action.Type != domain.ChannelActionChangeCommunity || action.CommunityID != 0 {
|
||||
t.Fatalf("unlink service action = %+v, want change-community(0)", action)
|
||||
}
|
||||
if got := ban.RemovedLinks[0].ServiceMessage.Channel.LinkedCommunityID; got != 0 {
|
||||
t.Fatalf("unlink service channel linked community = %d, want 0", got)
|
||||
}
|
||||
kicked, err := channels.GetParticipant(ctx, owner.ID, initial.ID, member.ID)
|
||||
if err != nil || kicked.Status != domain.ChannelMemberKicked || !kicked.BannedRights.ViewMessages {
|
||||
t.Fatalf("linked channel participant = %+v, err=%v, want kicked", kicked, err)
|
||||
}
|
||||
ownedView, err := channels.GetChannel(ctx, member.ID, owned.ID)
|
||||
if err != nil || ownedView.Channel.LinkedCommunityID != 0 {
|
||||
t.Fatalf("owned channel linked community = %d, err=%v, want 0", ownedView.Channel.LinkedCommunityID, err)
|
||||
}
|
||||
if _, err := store.GetCommunity(ctx, member.ID, created.Community.ID); !errors.Is(err, domain.ErrCommunityPrivate) {
|
||||
t.Fatalf("banned member get community error = %v, want private", err)
|
||||
}
|
||||
repeated, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_007)
|
||||
if err != nil || repeated.Changed || len(repeated.ChannelBans) != 0 || len(repeated.RemovedLinks) != 0 {
|
||||
t.Fatalf("repeated ban = %+v err=%v, want idempotent no-op", repeated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityCollapsedPinAndMixedOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := NewUserStore()
|
||||
owner := mustCommunityTestUser(t, users, "Owner", "15551000011")
|
||||
channels := NewChannelStore()
|
||||
store := NewCommunityStore(users, channels, nil, nil)
|
||||
|
||||
makeCommunity := func(title string) domain.CommunityView {
|
||||
channel := mustCommunityTestChannel(t, channels, owner, title+" Channel")
|
||||
view, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: title,
|
||||
InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_000_100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", title, err)
|
||||
}
|
||||
if _, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil || !changed {
|
||||
t.Fatalf("collapse %s: changed=%v err=%v", title, changed, err)
|
||||
}
|
||||
if changed, err := store.SetCommunityPinned(ctx, owner.ID, view.Community.ID, true); err != nil || !changed {
|
||||
t.Fatalf("pin %s: changed=%v err=%v", title, changed, err)
|
||||
}
|
||||
return view
|
||||
}
|
||||
one := makeCommunity("One")
|
||||
two := makeCommunity("Two")
|
||||
changed, err := store.ReorderCommunityPinned(ctx, owner.ID, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: 77},
|
||||
{Type: domain.PeerTypeCommunity, ID: one.Community.ID},
|
||||
{Type: domain.PeerTypeUser, ID: 88},
|
||||
{Type: domain.PeerTypeCommunity, ID: two.Community.ID},
|
||||
}, true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("mixed pinned reorder: changed=%v err=%v", changed, err)
|
||||
}
|
||||
oneView, _ := store.GetCommunity(ctx, owner.ID, one.Community.ID)
|
||||
twoView, _ := store.GetCommunity(ctx, owner.ID, two.Community.ID)
|
||||
if !oneView.State.Pinned || !twoView.State.Pinned || oneView.State.PinnedOrder <= twoView.State.PinnedOrder {
|
||||
t.Fatalf("pinned orders one=%+v two=%+v, want global mixed order preserved", oneView.State, twoView.State)
|
||||
}
|
||||
uncollapsed, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, one.Community.ID, false)
|
||||
if err != nil || !changed || uncollapsed.State.Pinned {
|
||||
t.Fatalf("uncollapse state = %+v changed=%v err=%v, want pin cleared", uncollapsed.State, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityCanUseOwnedBotAsInitialPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := NewUserStore()
|
||||
owner := mustCommunityTestUser(t, users, "Bot Owner", "15551000021")
|
||||
bots := NewBotStore(users)
|
||||
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: 91, FirstName: "Community Bot", Username: "community_bot",
|
||||
}, domain.BotProfile{OwnerUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
store := NewCommunityStore(users, NewChannelStore(), bots, nil)
|
||||
created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Bot Community",
|
||||
InitialPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID},
|
||||
Visibility: domain.CommunityPeerVisible,
|
||||
Date: 1_800_000_200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot community: %v", err)
|
||||
}
|
||||
if len(created.Links) != 1 || created.Links[0].Peer.ID != bot.ID || !created.Links[0].CanViewHistory {
|
||||
t.Fatalf("bot community links = %+v", created.Links)
|
||||
}
|
||||
if len(created.ServiceMessages) != 0 {
|
||||
t.Fatalf("bot link service messages = %+v, want none", created.ServiceMessages)
|
||||
}
|
||||
updatedBot, ok, err := users.ByID(ctx, bot.ID)
|
||||
if err != nil || !ok || updatedBot.LinkedCommunityID != created.Community.ID {
|
||||
t.Fatalf("bot linked community = %d ok=%v err=%v", updatedBot.LinkedCommunityID, ok, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -868,6 +868,14 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
|
|||
draft.WebPage = &webpage
|
||||
}
|
||||
draft.RichMessage = cloneRichMessage(draft.RichMessage)
|
||||
if draft.SuggestedPost != nil {
|
||||
suggested := *draft.SuggestedPost
|
||||
if suggested.Price != nil {
|
||||
price := *suggested.Price
|
||||
suggested.Price = &price
|
||||
}
|
||||
draft.SuggestedPost = &suggested
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
|
|
|
|||
380
internal/store/memory/ephemeral.go
Normal file
380
internal/store/memory/ephemeral.go
Normal 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
|
||||
}
|
||||
53
internal/store/memory/ephemeral_report.go
Normal file
53
internal/store/memory/ephemeral_report.go
Normal 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
|
||||
}
|
||||
184
internal/store/memory/ephemeral_test.go
Normal file
184
internal/store/memory/ephemeral_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ func normalizeMemoryMessageIDs(ids []int) []int {
|
|||
|
||||
func cloneMessage(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.Media = cloneRequestedPeerMedia(msg.Media)
|
||||
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
|
||||
msg.Forward = cloneMessageForward(msg.Forward)
|
||||
msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions)
|
||||
|
|
@ -99,13 +100,42 @@ func cloneMessage(msg domain.Message) domain.Message {
|
|||
return msg
|
||||
}
|
||||
|
||||
// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐
|
||||
// cloneRequestedPeerMedia isolates the immutable disclosure snapshot carried by
|
||||
// messageActionRequestedPeer. Other media payloads retain their established
|
||||
// copy behavior; this helper only deep-copies the newly mutable peer/photo slices.
|
||||
func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
|
||||
if media == nil {
|
||||
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
|
||||
}
|
||||
action := *media.ServiceAction
|
||||
requested := *media.ServiceAction.RequestedPeer
|
||||
requested.Peers = append([]domain.Peer(nil), requested.Peers...)
|
||||
requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...)
|
||||
for i := range requested.Details {
|
||||
requested.Details[i].Photo = domain.ClonePhotoPtr(requested.Details[i].Photo)
|
||||
}
|
||||
action.RequestedPeer = &requested
|
||||
clone.ServiceAction = &action
|
||||
return &clone
|
||||
}
|
||||
|
||||
// cloneReplyMarkup 深拷 reply markup 快照:与 postgres 每盒独立 decode 对齐
|
||||
// (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。
|
||||
func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
clone := domain.MessageReplyMarkup{}
|
||||
clone := *m
|
||||
if m.Inline != nil {
|
||||
clone.Inline = make([][]domain.MarkupButton, len(m.Inline))
|
||||
for i, row := range m.Inline {
|
||||
|
|
@ -117,6 +147,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
|||
clone.Inline[i] = cloneRow
|
||||
}
|
||||
}
|
||||
if m.Keyboard != nil {
|
||||
clone.Keyboard = make([][]domain.MarkupButton, len(m.Keyboard))
|
||||
for i, row := range m.Keyboard {
|
||||
clone.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// GetByUID resolves one owner's box row by the shared private message id. Callback delivery
|
||||
// uses it to translate the clicker's box id to the bot's box id without scanning history.
|
||||
func (s *MessageStore) GetByUID(_ context.Context, userID, uid int64) (domain.Message, bool, error) {
|
||||
if userID == 0 || uid == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
var found domain.Message
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.UID == uid {
|
||||
found = cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(found)
|
||||
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
|
||||
found.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if found.ID == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
items := []domain.Message{found}
|
||||
s.enrichPrivateMessagePolls(items, int(time.Now().Unix()))
|
||||
return items[0], true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
s.mu.RLock()
|
||||
messages := cloneMessages(s.m[userID])
|
||||
|
|
@ -235,11 +262,23 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
|
|||
})
|
||||
|
||||
query := strings.ToLower(filter.Query)
|
||||
peerIDs := make(map[int64]struct{}, len(filter.PeerIDs))
|
||||
for _, id := range filter.PeerIDs {
|
||||
peerIDs[id] = struct{}{}
|
||||
}
|
||||
base := make([]domain.Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if filter.HasPeer && msg.Peer != filter.Peer {
|
||||
continue
|
||||
}
|
||||
if filter.RestrictPeerIDs {
|
||||
if msg.Peer.Type != domain.PeerTypeUser {
|
||||
continue
|
||||
}
|
||||
if _, ok := peerIDs[msg.Peer.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
43
internal/store/memory/message_markup_test.go
Normal file
43
internal/store/memory/message_markup_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestPrivateReplyKeyboardSurvivesBothBoxesAndHistory(t *testing.T) {
|
||||
store := NewMessageStore(NewDialogStore())
|
||||
markup := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose",
|
||||
}
|
||||
res, err := store.SendPrivateText(context.Background(), domain.SendPrivateTextRequest{
|
||||
SenderUserID: 10, RecipientUserID: 20, RandomID: 30,
|
||||
Message: "pick", Date: 40, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertReplyKeyboard := func(name string, got *domain.MessageReplyMarkup) {
|
||||
t.Helper()
|
||||
if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 ||
|
||||
len(got.Keyboard[0]) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize ||
|
||||
!got.SingleUse || !got.Persistent || got.Placeholder != "Choose" {
|
||||
t.Fatalf("%s = %#v", name, got)
|
||||
}
|
||||
}
|
||||
assertReplyKeyboard("sender", res.SenderMessage.ReplyMarkup)
|
||||
assertReplyKeyboard("recipient", res.RecipientMessage.ReplyMarkup)
|
||||
markup.Keyboard[0][0].Text = "mutated"
|
||||
list, err := store.GetByIDs(context.Background(), 20, []int{res.RecipientMessage.ID})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
t.Fatalf("GetByIDs = %+v, %v", list, err)
|
||||
}
|
||||
assertReplyKeyboard("recipient history", list.Messages[0].ReplyMarkup)
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
Media: cloneRequestedPeerMedia(req.Media),
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
Effect: req.Effect,
|
||||
|
|
@ -110,6 +110,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
recipient.Out = false
|
||||
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
||||
recipient.Media = cloneRequestedPeerMedia(sender.Media)
|
||||
// recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷
|
||||
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。
|
||||
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)
|
||||
|
|
|
|||
|
|
@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
assertWebViewData("recipient history", recipientHistory.Messages[0])
|
||||
}
|
||||
|
||||
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
|
||||
}}}
|
||||
requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003}
|
||||
req := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 77, Peers: []domain.Peer{requestedPeer},
|
||||
Details: []domain.MessageRequestedPeerDetails{{
|
||||
Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo,
|
||||
}},
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
got, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertSnapshot := func(name string, msg domain.Message) {
|
||||
t.Helper()
|
||||
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
|
||||
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
|
||||
}
|
||||
action := msg.Media.ServiceAction.RequestedPeer
|
||||
if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
|
||||
len(action.Details) != 1 || action.Details[0].FirstName != "Shared" ||
|
||||
action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil ||
|
||||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
|
||||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
|
||||
t.Fatalf("%s requested-peer=%+v", name, action)
|
||||
}
|
||||
}
|
||||
assertSnapshot("sender", got.SenderMessage)
|
||||
assertSnapshot("recipient", got.RecipientMessage)
|
||||
|
||||
// Mutating either the request or one returned box must not alter the other
|
||||
// box or the immutable store snapshot.
|
||||
req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request"
|
||||
req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1
|
||||
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result"
|
||||
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2
|
||||
assertSnapshot("isolated recipient result", got.RecipientMessage)
|
||||
|
||||
for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} {
|
||||
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("owner %d history=%+v err=%v", owner, history, err)
|
||||
}
|
||||
assertSnapshot("stored history", history.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (do
|
|||
func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.createCatalogRevisionLocked(write)
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) createCatalogRevisionLocked(write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
giftID := write.GiftID
|
||||
if giftID == 0 {
|
||||
s.nextGiftID++
|
||||
|
|
@ -104,7 +108,21 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St
|
|||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.nextRevID++
|
||||
gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document}
|
||||
gift := domain.StarGift{
|
||||
ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars,
|
||||
Title: write.Title, Sticker: write.Document,
|
||||
Limited: write.Limited, SoldOut: write.SoldOut, Birthday: write.Birthday,
|
||||
RequirePremium: write.RequirePremium, LimitedPerUser: write.LimitedPerUser,
|
||||
PeerColorAvailable: write.PeerColorAvailable, Auction: write.Auction,
|
||||
AvailabilityRemains: write.AvailabilityRemains, AvailabilityTotal: write.AvailabilityTotal,
|
||||
AvailabilityResale: write.AvailabilityResale, FirstSaleDate: write.FirstSaleDate,
|
||||
LastSaleDate: write.LastSaleDate, ResellMinStars: write.ResellMinStars,
|
||||
ReleasedBy: write.ReleasedBy, PerUserTotal: write.PerUserTotal,
|
||||
PerUserRemains: write.PerUserTotal, LockedUntilDate: write.LockedUntilDate,
|
||||
AuctionSlug: write.AuctionSlug, GiftsPerRound: write.GiftsPerRound,
|
||||
AuctionStartDate: write.AuctionStartDate, UpgradeVariants: write.UpgradeVariants,
|
||||
Background: cloneStarGiftBackground(write.Background),
|
||||
}
|
||||
s.catalog[giftID] = gift
|
||||
s.revisions[gift.RevisionID] = gift
|
||||
s.enabled[giftID] = write.Enabled
|
||||
|
|
@ -113,6 +131,45 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St
|
|||
return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil
|
||||
}
|
||||
|
||||
func cloneStarGiftBackground(value *domain.StarGiftBackground) *domain.StarGiftBackground {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if write.Collectible != nil {
|
||||
collectibleWrite := *write.Collectible
|
||||
collectibleWrite.GiftID = write.Catalog.GiftID
|
||||
if collectibleWrite.GiftID == 0 {
|
||||
collectibleWrite.GiftID = s.nextGiftID + 1
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleWrite(collectibleWrite); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
entry, err := s.createCatalogRevisionLocked(write.Catalog)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
|
||||
if write.Collectible != nil {
|
||||
collectibleWrite := *write.Collectible
|
||||
collectibleWrite.GiftID = entry.Gift.ID
|
||||
revision, err := s.publishCollectibleRevisionLocked(collectibleWrite)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
result.Collectible = &revision
|
||||
result.Catalog.Gift = s.catalog[entry.Gift.ID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -148,6 +205,10 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma
|
|||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.publishCollectibleRevisionLocked(write)
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) publishCollectibleRevisionLocked(write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if _, ok := s.catalog[write.GiftID]; !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
|
@ -156,7 +217,8 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma
|
|||
ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1,
|
||||
UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal,
|
||||
SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true,
|
||||
CreatedBy: write.Actor,
|
||||
CreatedBy: write.Actor,
|
||||
OfficialGiftID: write.OfficialGiftID, SourceManifestSHA256: append([]byte(nil), write.SourceManifestSHA256...),
|
||||
}
|
||||
if revision.ID == 1 {
|
||||
revision.ID = write.GiftID*1000 + 1
|
||||
|
|
@ -263,6 +325,25 @@ func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (m
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if owner.ID <= 0 || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.UniqueStarGift, 0, min(limit, len(s.uniqueByID)))
|
||||
for _, gift := range s.uniqueByID {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if !validSavedStarGift(gift) {
|
||||
return 0, domain.ErrStarGiftInvalid
|
||||
|
|
@ -275,6 +356,7 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in
|
|||
gift.SavedID = gift.ID
|
||||
}
|
||||
gift.Converted = false
|
||||
gift.LifecycleStatus = domain.StarGiftLifecycleActive
|
||||
s.gifts = append(s.gifts, gift)
|
||||
return gift.ID, nil
|
||||
}
|
||||
|
|
@ -297,7 +379,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav
|
|||
defer s.mu.Unlock()
|
||||
matched := make([]domain.SavedStarGift, 0)
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner != owner || g.Converted {
|
||||
if g.Owner != owner || !g.LifecycleStatus.Live() {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnsaved && g.Unsaved {
|
||||
|
|
@ -329,28 +411,51 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav
|
|||
}
|
||||
matched = append(matched, g)
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool { return matched[i].ID > matched[j].ID })
|
||||
profileOrder := filter.CollectionID == 0
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
if profileOrder {
|
||||
iPinned := matched[i].PinnedOrder > 0
|
||||
jPinned := matched[j].PinnedOrder > 0
|
||||
if iPinned != jPinned {
|
||||
return iPinned
|
||||
}
|
||||
if iPinned && matched[i].PinnedOrder != matched[j].PinnedOrder {
|
||||
return matched[i].PinnedOrder < matched[j].PinnedOrder
|
||||
}
|
||||
}
|
||||
return matched[i].ID > matched[j].ID
|
||||
})
|
||||
page := domain.SavedStarGiftPage{Count: len(matched)}
|
||||
cursor, hasCursor := domain.DecodeStarGiftCursor(offset)
|
||||
out := make([]domain.SavedStarGift, 0, limit)
|
||||
cursor, hasCursor := domain.DecodeSavedStarGiftListCursor(offset)
|
||||
out := make([]domain.SavedStarGift, 0, limit+1)
|
||||
for _, g := range matched {
|
||||
if hasCursor && g.ID >= cursor {
|
||||
continue
|
||||
if hasCursor {
|
||||
if profileOrder {
|
||||
if cursor.PinnedOrder > 0 {
|
||||
if g.PinnedOrder > 0 && (g.PinnedOrder < cursor.PinnedOrder ||
|
||||
g.PinnedOrder == cursor.PinnedOrder && g.ID >= cursor.ID) {
|
||||
continue
|
||||
}
|
||||
} else if g.PinnedOrder > 0 || g.ID >= cursor.ID {
|
||||
continue
|
||||
}
|
||||
} else if g.ID >= cursor.ID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, g)
|
||||
if len(out) == limit {
|
||||
if len(out) == limit+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == limit {
|
||||
// 还有更早的则给下一页游标。
|
||||
last := out[len(out)-1].ID
|
||||
for _, g := range matched {
|
||||
if g.ID < last {
|
||||
page.NextOffset = domain.EncodeStarGiftCursor(last)
|
||||
break
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
last := out[len(out)-1]
|
||||
pinnedOrder := 0
|
||||
if profileOrder {
|
||||
pinnedOrder = last.PinnedOrder
|
||||
}
|
||||
page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID)
|
||||
}
|
||||
page.Gifts = out
|
||||
return page, nil
|
||||
|
|
@ -370,7 +475,7 @@ func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, re
|
|||
}
|
||||
var id int64
|
||||
for _, gift := range s.gifts {
|
||||
if savedStarGiftMatchesRef(gift, ref) && !gift.Converted {
|
||||
if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() {
|
||||
id = gift.ID
|
||||
break
|
||||
}
|
||||
|
|
@ -394,7 +499,7 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, g := range s.gifts {
|
||||
if savedStarGiftMatchesRef(g, ref) {
|
||||
if s.savedStarGiftMatchesRef(g, ref) {
|
||||
return g, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -409,7 +514,7 @@ func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int,
|
|||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner == owner && !g.Converted && !g.Unsaved {
|
||||
if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
|
@ -423,7 +528,7 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -438,7 +543,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.gifts[i].UniqueGiftID != 0 {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
|
|
@ -446,6 +551,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
|
|||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
s.gifts[i].Converted = true
|
||||
s.gifts[i].LifecycleStatus = domain.StarGiftLifecycleConverted
|
||||
s.gifts[i].Unsaved = true
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for collectionIndex := range s.collections[ref.Owner] {
|
||||
|
|
@ -640,7 +746,7 @@ func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []in
|
|||
}
|
||||
valid := false
|
||||
for _, gift := range s.gifts {
|
||||
if gift.ID == id && gift.Owner == owner && !gift.Converted {
|
||||
if gift.ID == id && gift.Owner == owner && gift.LifecycleStatus.Live() {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
|
|
@ -707,6 +813,7 @@ func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.St
|
|||
|
||||
func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision {
|
||||
out := in
|
||||
out.SourceManifestSHA256 = append([]byte(nil), in.SourceManifestSHA256...)
|
||||
clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute {
|
||||
copy := make([]domain.StarGiftCollectibleAttribute, len(attributes))
|
||||
for i, attribute := range attributes {
|
||||
|
|
@ -747,10 +854,14 @@ func validStarGiftOwner(owner domain.Peer) bool {
|
|||
return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool {
|
||||
func (s *StarGiftStore) savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool {
|
||||
if g.Owner != ref.Owner {
|
||||
return false
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
uniqueID, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(ref.Slug))]
|
||||
return ok && uniqueID != 0 && g.UniqueGiftID == uniqueID
|
||||
}
|
||||
switch ref.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return g.MsgID == ref.MsgID
|
||||
|
|
|
|||
41
internal/store/memory/star_gift_identity_test.go
Normal file
41
internal/store/memory/star_gift_identity_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSavedStarGiftIdentityDoesNotAcceptUpgradeMessageID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
store := NewStarGiftStore()
|
||||
id, err := store.Create(ctx, domain.SavedStarGift{
|
||||
Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 115,
|
||||
UniqueGiftID: 901, UpgradeMsgID: 116,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create saved gift: %v", err)
|
||||
}
|
||||
store.uniqueBySlug["official-8001-1"] = 901
|
||||
|
||||
canonical := domain.SavedStarGiftRef{Owner: owner, MsgID: 115}
|
||||
if saved, found, err := store.GetByRef(ctx, canonical); err != nil || !found || saved.ID != id {
|
||||
t.Fatalf("canonical identity: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
wrong := domain.SavedStarGiftRef{Owner: owner, MsgID: 116}
|
||||
if saved, found, err := store.GetByRef(ctx, wrong); err != nil || found {
|
||||
t.Fatalf("upgrade message id resolved gift: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{wrong}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("upgrade message id resolve err=%v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{
|
||||
canonical,
|
||||
{Owner: owner, Slug: "official-8001-1"},
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("duplicate official identities err=%v", err)
|
||||
}
|
||||
}
|
||||
69
internal/store/memory/star_gift_profile_order_test.go
Normal file
69
internal/store/memory/star_gift_profile_order_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
store := NewStarGiftStore()
|
||||
ids := make([]int64, 4)
|
||||
for i := range ids {
|
||||
id, err := store.Create(ctx, domain.SavedStarGift{
|
||||
Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 100 + i, Date: 1700000000 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create gift %d: %v", i, err)
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
|
||||
t.Fatalf("set pinned: %v", err)
|
||||
}
|
||||
|
||||
want := []int64{ids[0], ids[2], ids[3], ids[1]}
|
||||
var got []int64
|
||||
offset := ""
|
||||
for pageNumber := 0; ; pageNumber++ {
|
||||
page, err := store.ListByOwner(ctx, owner, false, offset, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("list page %d: %v", pageNumber, err)
|
||||
}
|
||||
if page.Count != len(ids) || len(page.Gifts) != 1 {
|
||||
t.Fatalf("page %d = %+v, want count=%d and one gift", pageNumber, page, len(ids))
|
||||
}
|
||||
got = append(got, page.Gifts[0].ID)
|
||||
if page.NextOffset == "" {
|
||||
break
|
||||
}
|
||||
offset = page.NextOffset
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("paged order = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, nil); err != nil {
|
||||
t.Fatalf("clear pinned: %v", err)
|
||||
}
|
||||
page, err := store.ListByOwner(ctx, owner, false, "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list after clear: %v", err)
|
||||
}
|
||||
want = []int64{ids[3], ids[2], ids[1], ids[0]}
|
||||
got = got[:0]
|
||||
for _, gift := range page.Gifts {
|
||||
got = append(got, gift.ID)
|
||||
if gift.PinnedOrder != 0 {
|
||||
t.Fatalf("gift %d pinned_order=%d after clear", gift.ID, gift.PinnedOrder)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("order after clear = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||
|
|
@ -66,7 +67,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool,
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if u.Phone == phone {
|
||||
if !u.Deleted && u.Phone == phone {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -105,6 +106,9 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
|
|||
out := make([]domain.User, 0, len(want))
|
||||
seenIDs := map[int64]struct{}{}
|
||||
for _, u := range s.byID {
|
||||
if u.Deleted {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[u.Phone]; !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -126,7 +130,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User,
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username {
|
||||
if !u.Deleted && strings.ToLower(u.Username) == username {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +145,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
|
|||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for id, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username && id != userID {
|
||||
if !u.Deleted && strings.ToLower(u.Username) == username && id != userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -161,7 +165,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
|
|||
defer s.mu.RUnlock()
|
||||
users := make([]domain.User, 0)
|
||||
for _, u := range s.byID {
|
||||
if u.ID == currentUserID {
|
||||
if u.ID == currentUserID || u.Deleted {
|
||||
continue
|
||||
}
|
||||
if userMatchesSearch(u, query, phoneQuery) {
|
||||
|
|
@ -183,7 +187,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if usernameLower != "" {
|
||||
|
|
@ -202,7 +206,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
u.FirstName = firstName
|
||||
|
|
@ -216,7 +220,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Birthday = birthday
|
||||
|
|
@ -228,7 +232,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.PersonalChannelID = channelID
|
||||
|
|
@ -242,7 +246,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) {
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
if !ok || u.Deleted || !u.Bot {
|
||||
return 0, false
|
||||
}
|
||||
u.BotInfoVersion++
|
||||
|
|
@ -255,7 +259,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
if !ok || u.Deleted || !u.Bot {
|
||||
return false
|
||||
}
|
||||
if setName {
|
||||
|
|
@ -273,7 +277,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if until < 0 {
|
||||
|
|
@ -289,7 +293,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Verified = verified
|
||||
|
|
@ -306,7 +310,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
|
|||
defer s.mu.Unlock()
|
||||
out := make([]domain.User, 0)
|
||||
for id, u := range s.byID {
|
||||
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||
if u.Deleted || u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||
continue
|
||||
}
|
||||
u.PremiumUntil = 0
|
||||
|
|
@ -320,19 +324,20 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(零值表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if documentID == 0 {
|
||||
until = 0
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
u.EmojiStatusDocumentID = documentID
|
||||
u.EmojiStatusUntil = until
|
||||
u.EmojiStatusDocumentID = status.DocumentID
|
||||
u.EmojiStatusUntil = status.Until
|
||||
u.EmojiStatusCollectible = status.Collectible
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
|
@ -341,7 +346,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if forProfile {
|
||||
|
|
@ -360,7 +365,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
if !ok || u.Deleted {
|
||||
return domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if lastSeenAt > u.LastSeenAt {
|
||||
|
|
@ -405,6 +410,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error
|
|||
}
|
||||
u.ID = s.nextID
|
||||
s.nextID++
|
||||
if u.CreatedAt.IsZero() {
|
||||
u.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
s.byID[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue