Merge pull request #2758 from bogdanovich/codex/telegram-media-groups

fix(telegram): media group album handling
This commit is contained in:
Mauro 2026-05-12 00:11:48 +02:00 committed by GitHub
commit 777269b429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 440 additions and 66 deletions

View file

@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"], "allow_from": ["123456789"],
"proxy": "", "proxy": "",
"use_markdown_v2": false "use_markdown_v2": false,
"media_group_delay_ms": 500
} }
} }
} }
@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | | allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | | proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | | use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms |
## Setup ## Setup

View file

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -43,11 +44,14 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`") reInlineCode = regexp.MustCompile("`([^`]+)`")
) )
const defaultMediaGroupDelay = 500 * time.Millisecond
type TelegramChannel struct { type TelegramChannel struct {
*channels.BaseChannel *channels.BaseChannel
bot *telego.Bot bot *telego.Bot
bh *th.BotHandler bh *th.BotHandler
bc *config.Channel bc *config.Channel
chatIDsMu sync.Mutex
chatIDs map[string]int64 chatIDs map[string]int64
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@ -57,6 +61,21 @@ type TelegramChannel struct {
registerFunc func(context.Context, []commands.Definition) error registerFunc func(context.Context, []commands.Definition) error
commandRegDelayFn func(int) time.Duration commandRegDelayFn func(int) time.Duration
commandRegCancel context.CancelFunc commandRegCancel context.CancelFunc
mediaGroupMu sync.Mutex
mediaGroups map[string]*telegramMediaGroup
mediaGroupDelay time.Duration
}
type telegramMediaGroup struct {
messages []*telego.Message
timer *time.Timer
generation uint64
}
type telegramMessageParts struct {
content []string
mediaPaths []string
} }
func NewTelegramChannel( func NewTelegramChannel(
@ -112,11 +131,21 @@ func NewTelegramChannel(
bc: bc, bc: bc,
chatIDs: make(map[string]int64), chatIDs: make(map[string]int64),
tgCfg: telegramCfg, tgCfg: telegramCfg,
mediaGroups: make(map[string]*telegramMediaGroup),
mediaGroupDelay: telegramMediaGroupDelay(telegramCfg),
} }
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
return ch, nil return ch, nil
} }
func telegramMediaGroupDelay(telegramCfg *config.TelegramSettings) time.Duration {
if telegramCfg != nil && telegramCfg.MediaGroupDelayMS > 0 {
return time.Duration(telegramCfg.MediaGroupDelayMS) * time.Millisecond
}
return defaultMediaGroupDelay
}
func (c *TelegramChannel) Start(ctx context.Context) error { func (c *TelegramChannel) Start(ctx context.Context) error {
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
@ -167,6 +196,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
if c.bh != nil { if c.bh != nil {
_ = c.bh.StopWithContext(ctx) _ = c.bh.StopWithContext(ctx)
} }
c.flushPendingMediaGroups(ctx)
// Cancel our context (stops long polling) // Cancel our context (stops long polling)
if c.cancel != nil { if c.cancel != nil {
@ -713,6 +743,131 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
} }
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
return c.bufferMediaGroupMessage(ctx, message)
}
return c.handleMessages(ctx, []*telego.Message{message})
}
func (c *TelegramChannel) bufferMediaGroupMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
return fmt.Errorf("message is nil")
}
groupID := strings.TrimSpace(message.MediaGroupID)
if groupID == "" {
return c.handleMessages(ctx, []*telego.Message{message})
}
msgCopy := *message
msgCopy.Photo = append([]telego.PhotoSize(nil), message.Photo...)
key := fmt.Sprintf("%d:%s", message.Chat.ID, groupID)
c.mediaGroupMu.Lock()
if c.mediaGroups == nil {
c.mediaGroups = make(map[string]*telegramMediaGroup)
}
group := c.mediaGroups[key]
if group == nil {
group = &telegramMediaGroup{}
c.mediaGroups[key] = group
}
group.messages = append(group.messages, &msgCopy)
group.generation++
generation := group.generation
if group.timer != nil {
group.timer.Stop()
}
delay := c.mediaGroupDelay
if delay <= 0 {
delay = defaultMediaGroupDelay
}
group.timer = time.AfterFunc(delay, func() {
c.flushMediaGroup(c.ctx, key, generation)
})
c.mediaGroupMu.Unlock()
logger.DebugCF("telegram", "Buffered media group message", map[string]any{
"chat_id": message.Chat.ID,
"media_group_id": groupID,
"message_id": message.MessageID,
})
return nil
}
func (c *TelegramChannel) flushPendingMediaGroups(ctx context.Context) {
c.mediaGroupMu.Lock()
keys := make([]string, 0, len(c.mediaGroups))
for key, group := range c.mediaGroups {
if group.timer != nil {
group.timer.Stop()
}
keys = append(keys, key)
}
c.mediaGroupMu.Unlock()
for _, key := range keys {
c.flushMediaGroup(ctx, key, 0)
}
}
func (c *TelegramChannel) flushMediaGroup(ctx context.Context, key string, generation uint64) {
c.mediaGroupMu.Lock()
group := c.mediaGroups[key]
if group == nil {
c.mediaGroupMu.Unlock()
return
}
if generation != 0 && group.generation != generation {
c.mediaGroupMu.Unlock()
return
}
delete(c.mediaGroups, key)
if group.timer != nil {
group.timer.Stop()
}
messages := append([]*telego.Message(nil), group.messages...)
c.mediaGroupMu.Unlock()
if len(messages) == 0 {
return
}
slices.SortFunc(messages, func(a, b *telego.Message) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return -1
case b == nil:
return 1
default:
return a.MessageID - b.MessageID
}
})
if ctx == nil {
ctx = context.Background()
}
if err := c.handleMessages(ctx, messages); err != nil {
logger.ErrorCF("telegram", "Failed to handle media group", map[string]any{
"key": key,
"error": err.Error(),
})
}
}
func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego.Message) error {
if len(messages) == 0 {
return nil
}
message := messages[0]
for _, candidate := range messages {
if candidate == nil {
continue
}
if strings.TrimSpace(candidate.Text) != "" || strings.TrimSpace(candidate.Caption) != "" {
message = candidate
break
}
}
if message == nil { if message == nil {
return fmt.Errorf("message is nil") return fmt.Errorf("message is nil")
} }
@ -740,7 +895,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
} }
chatID := message.Chat.ID chatID := message.Chat.ID
c.chatIDsMu.Lock()
c.chatIDs[platformID] = chatID c.chatIDs[platformID] = chatID
c.chatIDsMu.Unlock()
content := "" content := ""
mediaPaths := []string{} mediaPaths := []string{}
@ -764,61 +921,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return localPath // fallback: use raw path return localPath // fallback: use raw path
} }
if message.Text != "" { for i, msg := range messages {
content += message.Text if msg == nil {
continue
} }
parts := c.collectTelegramMessageParts(ctx, msg, i, len(messages), storeMedia)
if message.Caption != "" { for _, part := range parts.content {
if content != "" { if content != "" {
content += "\n" content += "\n"
} }
content += message.Caption content += part
}
if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
if content != "" {
content += "\n"
}
content += "[image: photo]"
}
}
if message.Voice != nil {
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
if voicePath != "" {
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
if content != "" {
content += "\n"
}
content += "[voice]"
}
}
if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
if audioPath != "" {
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
if content != "" {
content += "\n"
}
content += "[audio]"
}
}
if message.Document != nil {
docPath := c.downloadFile(ctx, message.Document.FileID, "")
if docPath != "" {
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
if content != "" {
content += "\n"
}
content += "[file]"
} }
mediaPaths = append(mediaPaths, parts.mediaPaths...)
} }
if content == "" && len(mediaPaths) == 0 { if content == "" && len(mediaPaths) == 0 {
@ -917,6 +1031,74 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil return nil
} }
func (c *TelegramChannel) collectTelegramMessageParts(
ctx context.Context,
msg *telego.Message,
index int,
total int,
storeMedia func(localPath, filename string) string,
) telegramMessageParts {
parts := telegramMessageParts{}
if msg == nil {
return parts
}
if text := strings.TrimSpace(msg.Text); text != "" {
parts.content = append(parts.content, text)
}
if caption := strings.TrimSpace(msg.Caption); caption != "" {
parts.content = append(parts.content, caption)
}
if len(msg.Photo) > 0 {
photo := msg.Photo[len(msg.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
photoNumber := index + 1
parts.mediaPaths = append(parts.mediaPaths, storeMedia(photoPath, fmt.Sprintf("photo-%d.jpg", photoNumber)))
parts.content = append(parts.content, fmt.Sprintf("[image: photo %d]", photoNumber))
}
}
if msg.Voice != nil {
voicePath := c.downloadFile(ctx, msg.Voice.FileID, ".ogg")
if voicePath != "" {
parts.mediaPaths = append(
parts.mediaPaths,
storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total)),
)
parts.content = append(parts.content, "[voice]")
}
}
if msg.Audio != nil {
audioPath := c.downloadFile(ctx, msg.Audio.FileID, ".mp3")
if audioPath != "" {
filename := msg.Audio.FileName
if strings.TrimSpace(filename) == "" {
filename = indexedMediaFilename("audio", ".mp3", index, total)
}
parts.mediaPaths = append(parts.mediaPaths, storeMedia(audioPath, filename))
parts.content = append(parts.content, "[audio]")
}
}
if msg.Document != nil {
docPath := c.downloadFile(ctx, msg.Document.FileID, "")
if docPath != "" {
filename := msg.Document.FileName
if strings.TrimSpace(filename) == "" {
filename = indexedMediaFilename("document", "", index, total)
}
parts.mediaPaths = append(parts.mediaPaths, storeMedia(docPath, filename))
parts.content = append(parts.content, "[file]")
}
}
return parts
}
func indexedMediaFilename(prefix, ext string, index int, total int) string {
if total <= 1 {
return prefix + ext
}
return fmt.Sprintf("%s-%d%s", prefix, index+1, ext)
}
func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string { func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
quoted := strings.TrimSpace(telegramQuotedContent(reply)) quoted := strings.TrimSpace(telegramQuotedContent(reply))
if quoted == "" { if quoted == "" {

View file

@ -10,6 +10,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi" ta "github.com/mymmrac/telego/telegoapi"
@ -1100,3 +1101,190 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
default: default:
} }
} }
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
base := testMediaGroupMessage("album-1")
first := base
first.MessageID = 1
second := base
second.MessageID = 2
second.Caption = "meal caption"
require.NoError(t, ch.handleMessage(context.Background(), &first))
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "2", inbound.Context.MessageID)
assert.Equal(t, "meal caption", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for combined media group message")
}
}
func TestHandleMessage_MediaGroupWaitsForStaggeredMessages(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(100 * time.Millisecond)
base := testMediaGroupMessage("album-staggered")
first := base
first.MessageID = 1
first.Caption = "first caption"
second := base
second.MessageID = 2
second.Caption = "second caption"
require.NoError(t, ch.handleMessage(context.Background(), &first))
time.Sleep(50 * time.Millisecond)
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
t.Fatalf("media group flushed before idle delay reset: %#v", inbound)
case <-time.After(75 * time.Millisecond):
}
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first caption\nsecond caption", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for staggered media group message")
}
}
func TestFlushMediaGroupIgnoresStaleTimerGeneration(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(time.Hour)
base := testMediaGroupMessage("album-generation")
first := base
first.MessageID = 1
first.Caption = "first"
second := base
second.MessageID = 2
second.Caption = "second"
key := "456:album-generation"
ch.mediaGroupMu.Lock()
ch.mediaGroups[key] = &telegramMediaGroup{
messages: []*telego.Message{&first, &second},
generation: 2,
}
ch.mediaGroupMu.Unlock()
ch.flushMediaGroup(context.Background(), key, 1)
select {
case inbound := <-messageBus.InboundChan():
t.Fatalf("stale media group generation flushed unexpectedly: %#v", inbound)
default:
}
ch.mediaGroupMu.Lock()
_, stillPending := ch.mediaGroups[key]
ch.mediaGroupMu.Unlock()
require.True(t, stillPending, "stale flush should leave the current batch pending")
ch.flushMediaGroup(context.Background(), key, 2)
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first\nsecond", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for current generation media group flush")
}
}
func TestHandleMessage_MediaGroupAfterDelayStartsNewBatch(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
base := testMediaGroupMessage("album-split")
first := base
first.MessageID = 1
first.Caption = "first"
second := base
second.MessageID = 2
second.Caption = "second"
require.NoError(t, ch.handleMessage(context.Background(), &first))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for first media group batch")
}
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "2", inbound.Context.MessageID)
assert.Equal(t, "second", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for second media group batch")
}
}
func TestStopFlushesPendingMediaGroups(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(time.Hour)
base := testMediaGroupMessage("album-stop")
msg := base
msg.MessageID = 1
msg.Caption = "caption before stop"
require.NoError(t, ch.handleMessage(context.Background(), &msg))
require.NoError(t, ch.Stop(context.Background()))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "caption before stop", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for pending media group flush on stop")
}
}
func TestNewTelegramChannelUsesConfiguredMediaGroupDelay(t *testing.T) {
ch, err := NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{
Token: *config.NewSecureString(testToken),
MediaGroupDelayMS: 750,
},
bus.NewMessageBus(),
)
require.NoError(t, err)
assert.Equal(t, 750*time.Millisecond, ch.mediaGroupDelay)
ch, err = NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{Token: *config.NewSecureString(testToken)},
bus.NewMessageBus(),
)
require.NoError(t, err)
assert.Equal(t, defaultMediaGroupDelay, ch.mediaGroupDelay)
}
func newMediaGroupTestChannel(delay time.Duration) (*bus.MessageBus, *TelegramChannel) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
mediaGroups: make(map[string]*telegramMediaGroup),
mediaGroupDelay: delay,
}
return messageBus, ch
}
func testMediaGroupMessage(mediaGroupID string) telego.Message {
return telego.Message{
Chat: telego.Chat{
ID: 456,
Type: "private",
},
From: &telego.User{
ID: 789,
FirstName: "User",
},
MediaGroupID: mediaGroupID,
}
}

View file

@ -485,6 +485,7 @@ type TelegramSettings struct {
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
MediaGroupDelayMS int `json:"media_group_delay_ms" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_MEDIA_GROUP_DELAY_MS"`
} }
type FeishuSettings struct { type FeishuSettings struct {

View file

@ -505,6 +505,7 @@ func defaultChannels() ChannelsConfig {
"settings": map[string]any{ "settings": map[string]any{
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
"use_markdown_v2": false, "use_markdown_v2": false,
"media_group_delay_ms": 500,
}, },
}, },
"feishu": map[string]any{}, "feishu": map[string]any{},