package rpc import ( "context" "fmt" "sync" "time" "telesrv/internal/domain" "telesrv/internal/store" ) type inlineRegistry struct { mu sync.Mutex pending map[int64]*pendingInlineQuery cache map[inlineCacheKey]cachedInlineResults prepared map[string]preparedInlineMessage webDocuments map[inlineWebDocumentKey]registeredInlineWebDocument ttl time.Duration maxCacheTTL time.Duration maxCacheEntries int shared store.InlineRegistryStore } type pendingInlineQuery struct { ch chan domain.BotInlineResults botUserID int64 userID int64 peer domain.Peer createdAt time.Time cacheKey inlineCacheKey results *domain.BotInlineResults } type inlineCacheKey struct { botUserID int64 userID int64 peer domain.Peer query string offset string hasGeo bool geoLat float64 geoLong float64 geoAccuracy int } type cachedInlineResults struct { results domain.BotInlineResults expiresAt time.Time storedAt time.Time } type preparedInlineMessage struct { id string botUserID int64 userID int64 results domain.BotInlineResults expiresAt time.Time } type inlineWebDocumentKey struct { url string accessHash int64 } type registeredInlineWebDocument struct { document domain.BotInlineWebDocument bytes []byte mimeType string expiresAt time.Time } func newInlineRegistry(ttl time.Duration, shared ...store.InlineRegistryStore) *inlineRegistry { if ttl <= 0 { ttl = botInlineQueryTTL } var sharedStore store.InlineRegistryStore if len(shared) > 0 { sharedStore = shared[0] } return &inlineRegistry{ pending: make(map[int64]*pendingInlineQuery), cache: make(map[inlineCacheKey]cachedInlineResults), prepared: make(map[string]preparedInlineMessage), webDocuments: make(map[inlineWebDocumentKey]registeredInlineWebDocument), ttl: ttl, maxCacheTTL: botInlineCacheMaxTTL, maxCacheEntries: botInlineCacheMaxEntries, shared: sharedStore, } } func (r *inlineRegistry) register(now time.Time, botUserID, userID int64, peer domain.Peer) (int64, *pendingInlineQuery) { return r.registerWithCacheKeyContext(context.Background(), now, botUserID, userID, peer, inlineCacheKey{}) } func (r *inlineRegistry) registerWithCacheKey(now time.Time, botUserID, userID int64, peer domain.Peer, key inlineCacheKey) (int64, *pendingInlineQuery) { return r.registerWithCacheKeyContext(context.Background(), now, botUserID, userID, peer, key) } func (r *inlineRegistry) registerWithCacheKeyContext(ctx context.Context, now time.Time, botUserID, userID int64, peer domain.Peer, key inlineCacheKey) (int64, *pendingInlineQuery) { p := &pendingInlineQuery{ ch: make(chan domain.BotInlineResults, 1), botUserID: botUserID, userID: userID, peer: peer, createdAt: now, cacheKey: key, } r.mu.Lock() r.pruneLocked(now) var queryID int64 for { queryID = randomNonZeroInt64() if _, exists := r.pending[queryID]; !exists { break } } r.pending[queryID] = p r.mu.Unlock() r.putSharedPending(ctx, queryID, p) return queryID, p } func (r *inlineRegistry) cachedContext(ctx context.Context, now time.Time, key inlineCacheKey) (domain.BotInlineResults, bool) { if key.botUserID == 0 { return domain.BotInlineResults{}, false } r.mu.Lock() r.pruneLocked(now) cached, ok := r.cache[key] if ok { if !cached.expiresAt.After(now) { delete(r.cache, key) } else { out := cloneInlineResults(cached.results) r.mu.Unlock() clampInlineCacheTime(&out, cached.expiresAt.Sub(now)) return out, true } } r.mu.Unlock() if r.shared == nil { return domain.BotInlineResults{}, false } out, ok, remaining, err := r.shared.GetInlineCache(ctx, storeInlineCacheKey(key)) if err != nil || !ok { return domain.BotInlineResults{}, false } out = cloneInlineResults(out) clampInlineCacheTime(&out, remaining) return out, true } func (r *inlineRegistry) registerCachedContext(ctx context.Context, now time.Time, botUserID, userID int64, peer domain.Peer, results domain.BotInlineResults) domain.BotInlineResults { clone := cloneInlineResults(results) clone.BotUserID = botUserID clone.UserID = userID clone.Peer = peer p := &pendingInlineQuery{ ch: make(chan domain.BotInlineResults, 1), botUserID: botUserID, userID: userID, peer: peer, createdAt: now, results: &clone, } r.mu.Lock() r.pruneLocked(now) var queryID int64 for { queryID = randomNonZeroInt64() if _, exists := r.pending[queryID]; !exists { break } } clone.QueryID = queryID r.pending[queryID] = p r.storeWebDocumentsLocked(now, clone) r.mu.Unlock() r.putSharedResult(ctx, clone, r.ttl) r.putSharedWebDocuments(ctx, now, clone) return clone } func (r *inlineRegistry) deregisterIfUnansweredContext(ctx context.Context, queryID int64) { deleteShared := false r.mu.Lock() if p, ok := r.pending[queryID]; ok && p.results == nil { delete(r.pending, queryID) deleteShared = true } r.mu.Unlock() if deleteShared && r.shared != nil { _ = r.shared.DeleteInlinePending(ctx, queryID) } } func (r *inlineRegistry) resolve(now time.Time, callerBotID, queryID int64, results domain.BotInlineResults) bool { return r.resolveContext(context.Background(), now, callerBotID, queryID, results) } func (r *inlineRegistry) resolveContext(ctx context.Context, now time.Time, callerBotID, queryID int64, results domain.BotInlineResults) bool { r.mu.Lock() r.pruneLocked(now) p, ok := r.pending[queryID] if ok && p.botUserID == callerBotID { results.QueryID = queryID results.BotUserID = p.botUserID results.UserID = p.userID results.Peer = p.peer results.Query = p.cacheKey.query if p.cacheKey.hasGeo { results.Geo = &domain.MessageGeoPoint{ Lat: p.cacheKey.geoLat, Long: p.cacheKey.geoLong, AccuracyRadius: p.cacheKey.geoAccuracy, } } clone := cloneInlineResults(results) p.results = &clone r.storeWebDocumentsLocked(now, clone) r.storeCacheLocked(now, p.cacheKey, clone) r.mu.Unlock() r.putSharedResolved(ctx, now, p.cacheKey, clone) select { case p.ch <- clone: default: } return true } r.mu.Unlock() if r.shared == nil { return false } sharedPending, found, err := r.shared.GetInlinePending(ctx, queryID) if err != nil || !found || sharedPending.BotUserID != callerBotID { return false } results.QueryID = queryID results.BotUserID = sharedPending.BotUserID results.UserID = sharedPending.UserID results.Peer = sharedPending.Peer results.Query = sharedPending.CacheKey.Query if sharedPending.CacheKey.HasGeo { results.Geo = &domain.MessageGeoPoint{ Lat: sharedPending.CacheKey.GeoLat, Long: sharedPending.CacheKey.GeoLong, AccuracyRadius: sharedPending.CacheKey.GeoAccuracy, } } clone := cloneInlineResults(results) r.putSharedResolved(ctx, now, inlineCacheKeyFromStore(sharedPending.CacheKey), clone) return true } func (r *inlineRegistry) resultsForQueryContext(ctx context.Context, now time.Time, userID, queryID int64) (domain.BotInlineResults, bool) { r.mu.Lock() r.pruneLocked(now) p, ok := r.pending[queryID] if ok && p.userID == userID && p.results != nil { results := cloneInlineResults(*p.results) r.mu.Unlock() return results, true } r.mu.Unlock() if r.shared == nil { return domain.BotInlineResults{}, false } results, found, err := r.shared.GetInlineResult(ctx, queryID) if err != nil || !found || results.UserID != userID { return domain.BotInlineResults{}, false } return cloneInlineResults(results), true } func (r *inlineRegistry) resultForSendContext(ctx context.Context, now time.Time, userID, queryID int64, id string) (domain.BotInlineResults, domain.BotInlineResult, bool) { r.mu.Lock() r.pruneLocked(now) p, ok := r.pending[queryID] if ok && p.userID == userID && p.results != nil { results, result, found := inlineResultByID(*p.results, id) r.mu.Unlock() return results, result, found } r.mu.Unlock() if r.shared == nil { return domain.BotInlineResults{}, domain.BotInlineResult{}, false } results, found, err := r.shared.GetInlineResult(ctx, queryID) if err != nil || !found || results.UserID != userID { return domain.BotInlineResults{}, domain.BotInlineResult{}, false } return inlineResultByID(results, id) } func (r *inlineRegistry) savePreparedInlineContext(ctx context.Context, now time.Time, botUserID, userID int64, result domain.BotInlineResult, peerTypes []string) (string, int) { ttl := r.ttl if ttl <= 0 { ttl = botInlineQueryTTL } results := domain.BotInlineResults{ BotUserID: botUserID, UserID: userID, Results: []domain.BotInlineResult{result}, CacheTime: int(ttl.Seconds()), PeerTypes: append([]string(nil), peerTypes...), } expiresAt := now.Add(ttl) r.mu.Lock() r.pruneLocked(now) id := "" for i := 0; i < 16; i++ { candidate := randomPreparedInlineID() if _, exists := r.prepared[candidate]; !exists { id = candidate break } } if id == "" { id = fmt.Sprintf("%016x%016x", uint64(now.UnixNano()), uint64(randomNonZeroInt64())) } msg := preparedInlineMessage{ id: id, botUserID: botUserID, userID: userID, results: cloneInlineResults(results), expiresAt: expiresAt, } r.prepared[id] = msg r.storeWebDocumentsLocked(now, msg.results) r.mu.Unlock() r.putSharedPrepared(ctx, msg, ttl) r.putSharedWebDocuments(ctx, now, msg.results) return id, int(expiresAt.Unix()) } func (r *inlineRegistry) preparedInlineContext(ctx context.Context, now time.Time, userID, botUserID int64, id string) (domain.BotInlineResults, bool) { var results domain.BotInlineResults found := false r.mu.Lock() r.pruneLocked(now) if msg, ok := r.prepared[id]; ok { if msg.expiresAt.After(now) && msg.userID == userID && msg.botUserID == botUserID { results = cloneInlineResults(msg.results) found = true } } r.mu.Unlock() if found { return r.registerCachedContext(ctx, now, botUserID, userID, domain.Peer{}, results), true } if r.shared == nil { return domain.BotInlineResults{}, false } shared, ok, err := r.shared.GetPreparedInlineMessage(ctx, id) if err != nil || !ok || shared.UserID != userID || shared.BotUserID != botUserID || !shared.ExpiresAt.After(now) { return domain.BotInlineResults{}, false } results = cloneInlineResults(shared.Results) return r.registerCachedContext(ctx, now, botUserID, userID, domain.Peer{}, results), true } func (r *inlineRegistry) consume(queryID int64) { r.consumeContext(context.Background(), queryID) } func (r *inlineRegistry) consumeContext(ctx context.Context, queryID int64) { r.mu.Lock() delete(r.pending, queryID) r.mu.Unlock() if r.shared != nil { _ = r.shared.DeleteInlinePending(ctx, queryID) _ = r.shared.DeleteInlineResult(ctx, queryID) } } func (r *inlineRegistry) unansweredSize() int { r.mu.Lock() defer r.mu.Unlock() count := 0 for _, p := range r.pending { if p.results == nil { count++ } } return count } func (r *inlineRegistry) pruneLocked(now time.Time) { if r.ttl > 0 { for queryID, p := range r.pending { if now.Sub(p.createdAt) > r.ttl { delete(r.pending, queryID) } } } for key, cached := range r.cache { if !cached.expiresAt.After(now) { delete(r.cache, key) } } for id, msg := range r.prepared { if !msg.expiresAt.After(now) { delete(r.prepared, id) } } for key, document := range r.webDocuments { if !document.expiresAt.After(now) { delete(r.webDocuments, key) } } } func (r *inlineRegistry) storeCacheLocked(now time.Time, key inlineCacheKey, results domain.BotInlineResults) { ttl, ok := r.cacheTTL(results) if key.botUserID == 0 || !ok || r.maxCacheEntries <= 0 { return } cached := cloneInlineResults(results) cached.QueryID = 0 r.dropOldestCacheEntryLocked(now) r.cache[key] = cachedInlineResults{ results: cached, expiresAt: now.Add(ttl), storedAt: now, } } func (r *inlineRegistry) dropOldestCacheEntryLocked(now time.Time) { if len(r.cache) < r.maxCacheEntries { return } var ( oldestKey inlineCacheKey oldestAt time.Time found bool ) for key, cached := range r.cache { if !cached.expiresAt.After(now) { delete(r.cache, key) continue } if !found || cached.storedAt.Before(oldestAt) { oldestKey = key oldestAt = cached.storedAt found = true } } if len(r.cache) >= r.maxCacheEntries && found { delete(r.cache, oldestKey) } } func (r *inlineRegistry) storeWebDocumentsLocked(now time.Time, results domain.BotInlineResults) { expiresAt := now.Add(r.webDocumentTTL(results)) for _, item := range results.Results { r.storeWebDocumentLocked(item.Thumb, expiresAt) r.storeWebDocumentLocked(item.Content, expiresAt) } } func (r *inlineRegistry) storeWebDocumentLocked(document *domain.BotInlineWebDocument, expiresAt time.Time) { if document == nil || document.URL == "" || document.AccessHash == 0 { return } key := inlineWebDocumentKey{url: document.URL, accessHash: document.AccessHash} clone := cloneInlineWebDocument(*document) existing := r.webDocuments[key] r.webDocuments[key] = registeredInlineWebDocument{ document: clone, bytes: append([]byte(nil), existing.bytes...), mimeType: existing.mimeType, expiresAt: expiresAt, } } func (r *inlineRegistry) webDocumentForDownloadContext(ctx context.Context, now time.Time, url string, accessHash int64) (domain.BotInlineWebDocument, []byte, string, bool) { r.mu.Lock() r.pruneLocked(now) registered, ok := r.webDocuments[inlineWebDocumentKey{url: url, accessHash: accessHash}] if ok { r.mu.Unlock() return cloneInlineWebDocument(registered.document), append([]byte(nil), registered.bytes...), registered.mimeType, true } r.mu.Unlock() if r.shared == nil { return domain.BotInlineWebDocument{}, nil, "", false } shared, ok, err := r.shared.GetInlineWebDocument(ctx, store.InlineWebDocumentKey{URL: url, AccessHash: accessHash}) if err != nil || !ok { return domain.BotInlineWebDocument{}, nil, "", false } return cloneInlineWebDocument(shared.Document), append([]byte(nil), shared.Bytes...), shared.MimeType, true } func (r *inlineRegistry) cacheWebDocumentBytesContext(ctx context.Context, now time.Time, url string, accessHash int64, data []byte, mimeType string) bool { if len(data) > domain.MaxBotInlineWebSize { return false } localUpdated := false r.mu.Lock() r.pruneLocked(now) key := inlineWebDocumentKey{url: url, accessHash: accessHash} registered, ok := r.webDocuments[key] if ok { registered.bytes = append([]byte(nil), data...) registered.mimeType = mimeType r.webDocuments[key] = registered localUpdated = true } r.mu.Unlock() if r.shared == nil { return localUpdated } err := r.shared.PutInlineWebDocumentBytes(ctx, store.InlineWebDocumentKey{URL: url, AccessHash: accessHash}, append([]byte(nil), data...), mimeType, r.ttl) return localUpdated || err == nil } func (r *inlineRegistry) registerWebDocumentContext(ctx context.Context, now time.Time, document domain.BotInlineWebDocument, ttl time.Duration) { if document.URL == "" || document.AccessHash == 0 { return } if ttl <= 0 { ttl = r.ttl } r.mu.Lock() r.pruneLocked(now) r.storeWebDocumentLocked(&document, now.Add(ttl)) r.mu.Unlock() r.putSharedWebDocument(ctx, &document, ttl) } func (r *inlineRegistry) putSharedPending(ctx context.Context, queryID int64, p *pendingInlineQuery) { if r.shared == nil { return } _ = r.shared.PutInlinePending(ctx, store.InlinePending{ QueryID: queryID, BotUserID: p.botUserID, UserID: p.userID, Peer: p.peer, CacheKey: storeInlineCacheKey(p.cacheKey), CreatedAt: p.createdAt, }, r.ttl) } func (r *inlineRegistry) putSharedResolved(ctx context.Context, now time.Time, key inlineCacheKey, results domain.BotInlineResults) { r.putSharedResult(ctx, results, r.ttl) r.putSharedCache(ctx, key, results) r.putSharedWebDocuments(ctx, now, results) } func (r *inlineRegistry) putSharedResult(ctx context.Context, results domain.BotInlineResults, ttl time.Duration) { if r.shared == nil || ttl <= 0 { return } _ = r.shared.PutInlineResult(ctx, cloneInlineResults(results), ttl) } func (r *inlineRegistry) putSharedCache(ctx context.Context, key inlineCacheKey, results domain.BotInlineResults) { if r.shared == nil || key.botUserID == 0 { return } ttl, ok := r.cacheTTL(results) if !ok { return } cached := cloneInlineResults(results) cached.QueryID = 0 _ = r.shared.PutInlineCache(ctx, storeInlineCacheKey(key), cached, ttl) } func (r *inlineRegistry) putSharedWebDocuments(ctx context.Context, now time.Time, results domain.BotInlineResults) { if r.shared == nil { return } ttl := r.webDocumentTTL(results) if ttl <= 0 { return } for _, item := range results.Results { r.putSharedWebDocument(ctx, item.Thumb, ttl) r.putSharedWebDocument(ctx, item.Content, ttl) } _ = now } func (r *inlineRegistry) putSharedWebDocument(ctx context.Context, document *domain.BotInlineWebDocument, ttl time.Duration) { if r.shared == nil || document == nil || document.URL == "" || document.AccessHash == 0 || ttl <= 0 { return } _ = r.shared.PutInlineWebDocument(ctx, cloneInlineWebDocument(*document), ttl) } func (r *inlineRegistry) putSharedPrepared(ctx context.Context, msg preparedInlineMessage, ttl time.Duration) { if r.shared == nil || ttl <= 0 { return } _ = r.shared.PutPreparedInlineMessage(ctx, store.PreparedInlineMessage{ ID: msg.id, BotUserID: msg.botUserID, UserID: msg.userID, Results: cloneInlineResults(msg.results), ExpiresAt: msg.expiresAt, }, ttl) } func (r *inlineRegistry) cacheTTL(results domain.BotInlineResults) (time.Duration, bool) { if results.Private || results.CacheTime <= 0 || r.maxCacheTTL <= 0 { return 0, false } ttl := time.Duration(results.CacheTime) * time.Second if ttl <= 0 { return 0, false } if ttl > r.maxCacheTTL { ttl = r.maxCacheTTL } return ttl, true } func (r *inlineRegistry) webDocumentTTL(results domain.BotInlineResults) time.Duration { ttl := r.ttl if cacheTTL, ok := r.cacheTTL(results); ok && cacheTTL > ttl { ttl = cacheTTL } return ttl } func inlineResultByID(results domain.BotInlineResults, id string) (domain.BotInlineResults, domain.BotInlineResult, bool) { for _, item := range results.Results { if item.ID == id { return cloneInlineResults(results), cloneInlineResult(item), true } } return domain.BotInlineResults{}, domain.BotInlineResult{}, false } func randomPreparedInlineID() string { return fmt.Sprintf("%016x%016x", uint64(randomNonZeroInt64()), uint64(randomNonZeroInt64())) } func clampInlineCacheTime(results *domain.BotInlineResults, remaining time.Duration) { seconds := int(remaining.Seconds()) if seconds <= 0 { seconds = 1 } if results.CacheTime <= 0 || seconds < results.CacheTime { results.CacheTime = seconds } } func storeInlineCacheKey(key inlineCacheKey) store.InlineCacheKey { return store.InlineCacheKey{ BotUserID: key.botUserID, UserID: key.userID, Peer: key.peer, Query: key.query, Offset: key.offset, HasGeo: key.hasGeo, GeoLat: key.geoLat, GeoLong: key.geoLong, GeoAccuracy: key.geoAccuracy, } } func inlineCacheKeyFromStore(key store.InlineCacheKey) inlineCacheKey { return inlineCacheKey{ botUserID: key.BotUserID, userID: key.UserID, peer: key.Peer, query: key.Query, offset: key.Offset, hasGeo: key.HasGeo, geoLat: key.GeoLat, geoLong: key.GeoLong, geoAccuracy: key.GeoAccuracy, } } func cloneInlineResults(in domain.BotInlineResults) domain.BotInlineResults { if in.Geo != nil { geo := *in.Geo in.Geo = &geo } if in.SwitchPM != nil { switchPM := *in.SwitchPM in.SwitchPM = &switchPM } if in.SwitchWeb != nil { switchWeb := *in.SwitchWeb in.SwitchWeb = &switchWeb } in.PeerTypes = append([]string(nil), in.PeerTypes...) in.Results = append([]domain.BotInlineResult(nil), in.Results...) for i := range in.Results { in.Results[i] = cloneInlineResult(in.Results[i]) } return in } func cloneInlineResult(in domain.BotInlineResult) domain.BotInlineResult { in.Entities = append([]domain.MessageEntity(nil), in.Entities...) if in.Thumb != nil { thumb := cloneInlineWebDocument(*in.Thumb) in.Thumb = &thumb } if in.Content != nil { content := cloneInlineWebDocument(*in.Content) in.Content = &content } in.ReplyMarkup = cloneInlineReplyMarkup(in.ReplyMarkup) in.Media = cloneInlineMedia(in.Media) return in } func cloneInlineWebDocument(in domain.BotInlineWebDocument) domain.BotInlineWebDocument { in.Attributes = append([]domain.DocumentAttribute(nil), in.Attributes...) return in } func cloneInlineReplyMarkup(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { if in == nil { return nil } out := domain.MessageReplyMarkup{} if in.Inline != nil { out.Inline = make([][]domain.MarkupButton, len(in.Inline)) for i, row := range in.Inline { out.Inline[i] = make([]domain.MarkupButton, len(row)) for j, button := range row { out.Inline[i][j] = button out.Inline[i][j].Data = append([]byte(nil), button.Data...) } } } return &out } func cloneInlineMedia(in *domain.MessageMedia) *domain.MessageMedia { if in == nil { return nil } out := *in if in.Photo != nil { photo := *in.Photo photo.FileReference = append([]byte(nil), in.Photo.FileReference...) photo.Sizes = cloneInlinePhotoSizes(in.Photo.Sizes) out.Photo = &photo } if in.Document != nil { doc := *in.Document doc.FileReference = append([]byte(nil), in.Document.FileReference...) doc.Attributes = append([]domain.DocumentAttribute(nil), in.Document.Attributes...) doc.Thumbs = cloneInlinePhotoSizes(in.Document.Thumbs) out.Document = &doc } if in.Geo != nil { geo := *in.Geo out.Geo = &geo } if in.Venue != nil { venue := *in.Venue out.Venue = &venue } if in.Contact != nil { contact := *in.Contact out.Contact = &contact } return &out } func cloneInlinePhotoSizes(in []domain.PhotoSize) []domain.PhotoSize { if len(in) == 0 { return nil } out := append([]domain.PhotoSize(nil), in...) for i := range out { out[i].Bytes = append([]byte(nil), in[i].Bytes...) out[i].Sizes = append([]int(nil), in[i].Sizes...) out[i].BackgroundColors = append([]int(nil), in[i].BackgroundColors...) } return out }