mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-07-09 17:18:37 +00:00
Some checks failed
Build / build (push) Has been cancelled
- 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
276 lines
9.5 KiB
Go
276 lines
9.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|