Files
go-telegram-bot/album_buffer.go
T
2026-05-28 20:54:09 +02:00

123 lines
4.4 KiB
Go

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
isNewChat, isOwner bool
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,
isNewChat, isOwner bool,
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,
isNewChat: isNewChat,
isOwner: isOwner,
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.isNewChat, captured.isOwner,
captured.businessConnectionID,
)
}