Preserve links in TG→MAX crossposts; deliver text when video fails

Two issues reported by a user via the bridge:

1. Links vanish on TG channel → MAX crosspost. formatTgCrosspostCaption
   returned raw text and forwardTgToMax dropped markdown format for the
   crosspost branch, so text_link entities became plain text. Now
   formatTgCrosspostCaption runs tgEntitiesToMarkdown, and
   forwardTgToMax always uses markdown. Media group flusher also
   skips the second conversion for crosspost items (caption is already
   markdown) and keeps markdown format.

2. When a channel video is too big for Bot API getFile (e.g. HD > 2 GB
   on local server), the whole post was dropped — even the caption
   text was lost. Video upload failure no longer returns from
   forwardTgToMax; the fallback branch appends a "[Видео]" marker so
   subscribers in MAX at least get the text and know a video was
   attached but not relayed. The fallback condition now checks
   presence of any media instead of only msg.Text == "" (it missed
   video-with-caption posts) and also covers Animation and Photo.

Tradeoff: crosspost replacements now operate on markdown text. URL-
and phrase-level replacements keep working; regex rules touching
markdown characters (_, *, [, ]) may need adjustment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-24 16:26:24 +04:00
parent 49af1b6432
commit 63b95e4c8c
3 changed files with 29 additions and 14 deletions

View file

@ -84,13 +84,17 @@ func formatMaxCaption(upd *maxschemes.MessageCreatedUpdate, prefix, newline bool
return formatAttribution(name, text, newline)
}
// formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
// formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов).
// Конвертирует entities в markdown, чтобы ссылки и форматирование сохранились
// при пересылке в MAX. Replacements, если настроены, применяются поверх markdown.
func formatTgCrosspostCaption(msg *TGMessage) string {
text := msg.Text
entities := msg.Entities
if text == "" {
text = msg.Caption
entities = msg.CaptionEntities
}
return text
return tgEntitiesToMarkdown(text, entities)
}
// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)

View file

@ -114,14 +114,17 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
}
}
// Форматируем caption
// Форматируем caption.
// Для crosspost caption уже в markdown (см. formatTgCrosspostCaption),
// повторно конвертировать нельзя — entities ссылаются на offsets сырого текста.
mdCaption := caption
if entities != nil {
if entities != nil && !isCrosspost {
mdCaption = tgEntitiesToMarkdown(caption, entities)
}
m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption)
if mdCaption != caption {
// Для crosspost caption уже markdown; для bridge — markdown если были entities.
if isCrosspost || mdCaption != caption {
m.SetFormat("markdown")
}
if replyTo != "" {

View file

@ -732,7 +732,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
slog.Error("TG→MAX video upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX", name), err), nil)
return
// Видео не залилось — не блокируем отправку текста/подписи, fallback ниже подмешает [Видео].
}
} else if msg.VideoNote != nil {
if checkSize(msg.VideoNote.FileSize, "circle.mp4") {
@ -854,8 +854,11 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mdText = tgEntitiesToMarkdown(rawText, entities)
}
// Fallback для неудавшейся загрузки медиа
if mediaAttType == "" && msg.Text == "" {
// Fallback: медиа было, но не загрузилось (либо не хватило места, либо Bot API
// не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл.
hasMedia := msg.Video != nil || msg.VideoNote != nil || msg.Document != nil ||
msg.Voice != nil || msg.Audio != nil || msg.Sticker != nil || msg.Animation != nil || msg.Photo != nil
if mediaAttType == "" && hasMedia {
mediaType := ""
switch {
case msg.Video != nil:
@ -870,10 +873,16 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mediaType = "[Аудио]"
case msg.Sticker != nil:
mediaType = "[Стикер]"
default:
return
case msg.Animation != nil:
mediaType = "[GIF]"
case msg.Photo != nil:
mediaType = "[Фото]"
}
if mdText != "" {
mdText = mdText + "\n" + mediaType
} else {
mdText = mediaType
}
mdText = mdText + mediaType
}
// Reply ID
@ -897,10 +906,9 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
// Если для этого чата уже есть сообщения в очереди — не отправляем напрямую,
// чтобы не нарушить порядок. Сразу ставим в очередь.
// Caption для crosspost уже содержит markdown (formatTgCrosspostCaption),
// так что формат одинаков для обоих режимов.
format := "markdown"
if isCrosspost {
format = ""
}
if b.hasPendingForChat("tg2max", maxChatID) {
slog.Info("TG→MAX queued (pending exists)", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)