package main import ( "context" "sort" "time" "github.com/go-telegram/bot/models" ) // albumFlushWindow is the debounce delay before a buffered Telegram media_group // is flushed as a single coalesced user turn. 1s matches the de-facto community // standard across the dominant third-party album plugins (aiogram-media-group, // DieTime/telegraf-media-group) and sits above the sub-100ms values documented // as lossy under network jitter (openclaw#1811). Telegram has no official // "album complete" signal, so timeout-based flush is the only option. const albumFlushWindow = 1 * time.Second // pendingAlbum holds a Telegram media_group as its items arrive, plus the // per-user metadata captured from the first item. All items in an album share // the same chat/user, so we record metadata once and reuse it at flush time. type pendingAlbum struct { items []*models.Message // Metadata captured from the first arriving item. Albums always come from // the same user/chat, so these are stable across the buffering window. chatID, userID int64 username, firstName, lastName, languageCode string isPremium bool messageTime int businessConnectionID string // timer flushes the album after albumFlushWindow with no further arrivals. // Each new arrival stops the previous timer (best-effort) and installs a // fresh one — the standard debounce pattern. timer *time.Timer } // bufferAlbumItem appends an incoming Telegram album item to the per-MediaGroupID // buffer. On first arrival it captures the user/chat metadata and starts the // flush timer; on subsequent arrivals it appends the item and extends the timer. // The 1s debounce gives the rest of the album time to arrive over the network. func (b *Bot) bufferAlbumItem( ctx context.Context, msg *models.Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string, ) { b.albumBuffersMu.Lock() defer b.albumBuffersMu.Unlock() album, exists := b.albumBuffers[msg.MediaGroupID] if !exists { album = &pendingAlbum{ chatID: chatID, userID: userID, username: username, firstName: firstName, lastName: lastName, isPremium: isPremium, languageCode: languageCode, messageTime: messageTime, businessConnectionID: businessConnectionID, } b.albumBuffers[msg.MediaGroupID] = album } album.items = append(album.items, msg) // Stop the previous timer best-effort; even if it already fired the race // is benign because flushAlbum removes the map entry under the lock — a // late arrival would simply seed a fresh album. if album.timer != nil { album.timer.Stop() } mediaGroupID := msg.MediaGroupID album.timer = time.AfterFunc(albumFlushWindow, func() { b.flushAlbum(ctx, mediaGroupID) }) } // flushAlbum is called by the flush timer (or by code that needs to force-flush // during shutdown). It removes the album from the buffer, sorts items by // message_id (Telegram does not guarantee in-order arrival), runs the rate-limit // check once, and dispatches to handlePhotoMessage. func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) { b.albumBuffersMu.Lock() album, exists := b.albumBuffers[mediaGroupID] if !exists { b.albumBuffersMu.Unlock() return } delete(b.albumBuffers, mediaGroupID) items := album.items captured := *album // copy fields for use after unlock b.albumBuffersMu.Unlock() // Sort by Telegram message_id: items in an album arrive as separate Updates // over the network and may interleave. Sorting restores the user's intended // order before we hand them to Claude. sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) // Rate-limit fires once per coalesced album, not once per item. if !b.checkRateLimits(captured.userID) { b.sendRateLimitExceededMessage(ctx, captured.chatID, captured.businessConnectionID) return } b.handlePhotoMessage( ctx, items, captured.chatID, captured.userID, captured.username, captured.firstName, captured.lastName, captured.isPremium, captured.languageCode, captured.messageTime, captured.businessConnectionID, ) }