max-telegram-bridge-bot/mediagroup.go
Andrey Lugovskoy f57105d2b6
Some checks failed
Build / build (push) Has been cancelled
crosspost: fix long album caption + strip custom emoji and whitespace padding
- MAX→TG album: caption over Telegram's 1024 media-group limit is no longer lost —
  the album is sent without an inline caption and the full text follows as a
  separate message (sendMaxAlbumToTg, covers both direct and queued delivery).
- Forwarded text: strip Telegram custom_emoji (MAX can't render them; the fallback
  glyph only littered the copy) in tgEntitiesToHTML, and collapse whitespace padding
  (runs of spaces/nbsp, trailing spaces, 3+ blank lines) in crosspost captions —
  cuts bloated length (MAX ~4000 char limit) and fixes ugly gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u
2026-07-03 13:00:46 +04:00

276 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
maxbot "github.com/max-messenger/max-bot-api-client-go"
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
)
const mediaGroupTimeout = 1 * time.Second
// mediaGroupItem хранит данные одного сообщения из альбома TG.
type mediaGroupItem struct {
photoSizes []PhotoSize
videoFileID string // для видео в альбомах
caption string
replyToMsg *TGMessage
entities []Entity
msg *TGMessage
maxChatID int64 // если задан — используется напрямую (crosspost)
crosspost bool // кросспостинг: без prefix, другой caption формат
}
// mediaGroupBuffer накапливает сообщения альбома перед отправкой.
type mediaGroupBuffer struct {
mu sync.Mutex
items []mediaGroupItem
timer *time.Timer
}
// bufferMediaGroup добавляет сообщение в буфер альбома.
// Если это первое сообщение — запускает таймер.
func (b *Bridge) bufferMediaGroup(ctx context.Context, groupID string, item mediaGroupItem) {
b.mgMu.Lock()
buf, ok := b.mgBuffers[groupID]
if !ok {
buf = &mediaGroupBuffer{}
b.mgBuffers[groupID] = buf
// Добавляем первый item до запуска таймера — исключает гонку
buf.items = append(buf.items, item)
buf.timer = time.AfterFunc(mediaGroupTimeout, func() {
b.flushMediaGroup(ctx, groupID)
})
b.mgMu.Unlock()
return
}
b.mgMu.Unlock()
buf.mu.Lock()
buf.items = append(buf.items, item)
// Debounce: продлеваем окно сбора на каждый новый элемент. При импорте части
// альбома приходят с задержкой (форвард+загрузка), и фиксированный таймер от
// первого элемента флашил бы группу до прихода остальных, разбивая альбом.
buf.timer.Reset(mediaGroupTimeout)
buf.mu.Unlock()
}
// flushMediaGroup отправляет все накопленные фото/видео альбома одним сообщением в MAX.
func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
b.mgMu.Lock()
buf, ok := b.mgBuffers[groupID]
if !ok {
b.mgMu.Unlock()
return
}
delete(b.mgBuffers, groupID)
b.mgMu.Unlock()
buf.mu.Lock()
buf.timer.Stop()
items := buf.items
buf.mu.Unlock()
if len(items) == 0 {
return
}
// Дедуп альбома: если первый элемент уже доставлен в MAX — пропускаем
// (защита от повторной обработки/реплея после рестарта).
if b.alreadyDeliveredToMax(items[0].msg.Chat.ID, items[0].msg.MessageID) {
slog.Info("skip duplicate media group", "tgChat", items[0].msg.Chat.ID, "tgMsg", items[0].msg.MessageID)
return
}
// Определяем maxChatID
isCrosspost := items[0].crosspost
maxChatID := items[0].maxChatID
if maxChatID == 0 {
var linked bool
maxChatID, linked = b.repo.GetMaxChat(items[0].msg.Chat.ID)
if !linked {
slog.Warn("media group: chat not linked", "tgChat", items[0].msg.Chat.ID)
return
}
}
// Пауза связки — альбом тоже не пересылаем.
if isCrosspost {
if b.repo.CrosspostPaused(maxChatID) {
return
}
} else if b.repo.PairPaused(items[0].msg.Chat.ID, maxChatID) {
return
}
// Дуал-бот: токен бота этого чата в ctx (аплоады + sendMaxDirect альбома идут им).
ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID))
mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал)
uid := tgUserID(items[0].msg)
prefix := !isCrosspost && b.hasPrefix("tg", items[0].msg.Chat.ID)
// Кнопка мини-аппа под кросспостом-альбомом (комментарии и т.п.).
var openApp *maxOpenApp
if isCrosspost {
openApp = b.crosspostOpenApp(ctx, items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID)
}
// Caption и entities берём из первого элемента, у которого caption не пустой
var caption string
var entities []Entity
for _, it := range items {
if it.caption != "" {
caption = it.caption
entities = it.entities
break
}
}
// Reply ID из первого элемента с reply
var replyTo string
for _, it := range items {
if it.replyToMsg != nil {
if maxReplyID, ok := b.repo.LookupMaxMsgID(it.msg.Chat.ID, it.replyToMsg.MessageID); ok {
replyTo = maxReplyID
}
break
}
}
// Форматируем caption.
// Для crosspost caption уже в markdown (см. formatTgCrosspostCaption),
// повторно конвертировать нельзя — entities ссылаются на offsets сырого текста.
mdCaption := caption
if entities != nil && !isCrosspost {
mdCaption = tgEntitiesToHTML(caption, entities)
}
m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption)
// Для crosspost caption уже markdown; для bridge — markdown если были entities.
if isCrosspost || mdCaption != caption {
m.SetFormat("html")
}
if replyTo != "" {
m.SetReply(mdCaption, replyTo)
}
if kb := b.openAppKeyboard(openApp); kb != nil {
m.AddKeyboard(kb)
}
// Загружаем и добавляем все фото
photosSent := 0
var photoFailErr error
for _, it := range items {
if len(it.photoSizes) > 0 {
photo := it.photoSizes[len(it.photoSizes)-1]
fileURL, err := b.tgFileURL(ctx, photo.FileID)
if err != nil {
slog.Error("media group: tgFileURL failed", "err", err)
photoFailErr = err
continue
}
// Если custom TG API — MAX не может скачать по URL, скачиваем сами
if b.cfg.TgAPIURL != "" {
uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID)
if err != nil {
slog.Error("media group: photo upload failed", "err", err)
photoFailErr = err
continue
}
m.AddPhoto(uploaded)
} else {
uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL)
if err != nil {
slog.Error("media group: photo upload failed", "err", err)
photoFailErr = err
continue
}
m.AddPhoto(uploaded)
}
photosSent++
}
}
if photoFailErr != nil && photosSent == 0 {
b.notifyTgUser(ctx, items[0].msg, maxChatID,
uploadErrMsg("Не удалось отправить альбом в MAX", photoFailErr), isCrosspost)
}
// Загружаем видео из альбома через direct API
videosSent := 0
var videoTokens []string
for _, it := range items {
if it.videoFileID != "" {
uploaded, err := b.uploadTgMediaToMax(ctx, it.videoFileID, maxschemes.VIDEO, "video.mp4")
if err != nil {
slog.Error("media group: video upload failed", "err", err)
continue
}
videoTokens = append(videoTokens, uploaded.Token)
videosSent++
}
}
totalMedia := photosSent + videosSent
if totalMedia == 0 {
slog.Warn("media group: no media uploaded, skipping")
return
}
slog.Info("TG→MAX sending media group", "photos", photosSent, "videos", videosSent, "uid", uid, "tgChat", items[0].msg.Chat.ID, "maxChat", maxChatID)
// Если есть фото — отправляем через SDK (поддерживает AddPhoto)
if photosSent > 0 {
result, err := mc.Messages.SendWithResult(ctx, m)
if err != nil {
slog.Error("TG→MAX media group send failed", "err", err)
if isChatUnavailable(err.Error()) {
b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM
} else if b.cbFail(maxChatID) {
b.notifyTgUser(ctx, items[0].msg, maxChatID,
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
}
// Fallback — по одному
for _, it := range items {
var cap string
if isCrosspost {
cap = formatTgCrosspostCaption(it.msg)
// Применяем замены для TG→MAX
repl := b.repo.GetCrosspostReplacements(maxChatID)
if len(repl.TgToMax) > 0 {
cap = applyReplacements(cap, repl.TgToMax)
}
} else {
cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline)
}
go b.forwardTgToMax(ctx, it.msg, maxChatID, cap, isCrosspost, false)
}
return
}
b.cbSuccess(maxChatID)
slog.Info("TG→MAX media group sent", "mid", result.Body.Mid, "photos", photosSent)
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid, items[0].msg.MessageThreadID)
}
// Видео отправляем отдельно через direct API (SDK не поддерживает AddVideo)
for i, token := range videoTokens {
videoCaption := ""
var vkb *maxOpenApp
if i == 0 && photosSent == 0 {
videoCaption = mdCaption // caption на первое видео если нет фото
vkb = openApp // кнопку — на первое видео, если фото не было
}
mid, err := b.sendMaxDirectFormattedKb(ctx, maxChatID, videoCaption, "video", token, "", "", vkb)
if err != nil {
slog.Error("TG→MAX media group video send failed", "err", err)
continue
}
if i == 0 && photosSent == 0 {
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid, items[0].msg.MessageThreadID)
}
}
}