feat: sync bot keyboards and callbacks

Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 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,56 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
MessageID: req.MessageID,
SourcePts: req.SourcePts,
Date: req.Date,
Callback: cloneBotAPICallback(req.Callback),
}
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 +332,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 +415,65 @@ 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.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)
}
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)
return row
}
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,191 @@
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)
}
}

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,36 @@ 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.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 +141,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])

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