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.
479 lines
14 KiB
Go
479 lines
14 KiB
Go
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
|
|
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),
|
|
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
|
|
}
|
|
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 {
|
|
return cloneBotAPIUpdate(row), false, nil
|
|
}
|
|
}
|
|
}
|
|
row := domain.BotAPIUpdate{
|
|
ID: s.nextID,
|
|
BotUserID: req.BotUserID,
|
|
Kind: req.Kind,
|
|
Peer: req.Peer,
|
|
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
|
|
}
|
|
if fromUpdateID <= 0 {
|
|
fromUpdateID = 1
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 100
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]domain.BotAPIUpdate, 0, limit)
|
|
for _, row := range s.rows {
|
|
if row.BotUserID != botUserID || row.ID < fromUpdateID {
|
|
continue
|
|
}
|
|
out = append(out, cloneBotAPIUpdate(row))
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, confirmedUpdateID int64) error {
|
|
if botUserID == 0 || confirmedUpdateID <= 0 {
|
|
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
|
|
}
|
|
s.mu.RLock()
|
|
id, ok := s.state[botUserID]
|
|
s.mu.RUnlock()
|
|
return id, ok, nil
|
|
}
|
|
|
|
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
|
if req.BotUserID == 0 {
|
|
return fmt.Errorf("invalid bot api update")
|
|
}
|
|
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 || 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
|
|
}
|