Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -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
}

View 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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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{} {

View file

@ -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 子会话内的重发消息。

View file

@ -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])
}
}

View file

@ -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),
}
}

View file

@ -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()

File diff suppressed because it is too large Load diff

View 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)
}
}

View file

@ -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
}

View file

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

View file

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

View file

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

View file

@ -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
}

View file

@ -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
}

View 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)
}

View file

@ -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)

View file

@ -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()

View file

@ -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 &copy
}
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

View 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)
}
}

View 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)
}
}

View file

@ -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 statusdocumentID=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
}