Fix formatting in both TG→MAX and MAX→TG directions
Some checks are pending
Build / build (push) Waiting to run

TG→MAX: tgEntitiesToMarkdown was called on caption with "Name: " prefix,
but entity offsets are relative to raw text — formatting markers landed
in wrong positions. Now entities are converted on raw text first, then
attribution is applied.

MAX→TG: condition `caption == text` was always false for bridge chats
(caption has "Name: " prefix), so markups were never converted to HTML.
Now markups are always applied, with proper HTML-escaped attribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-08 18:40:37 +03:00
parent ec166754c3
commit 3bd42a5a5e
2 changed files with 113 additions and 48 deletions

66
max.go
View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"html"
"log/slog"
"net/http"
"strconv"
@ -93,11 +94,28 @@ func (b *Bridge) listenMax(ctx context.Context) {
if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") {
continue
}
// Конвертируем markups в HTML если есть
var fwd string
if prefix {
fwd = formatAttribution("[MAX] "+name, text, b.cfg.MessageNewline)
var editParseMode string
if len(editUpd.Message.Body.Markups) > 0 {
htmlText := maxMarkupsToHTML(text, editUpd.Message.Body.Markups)
escapedName := html.EscapeString(name)
if prefix {
escapedName = "[MAX] " + escapedName
}
if b.cfg.MessageNewline {
fwd = escapedName + ":\n" + htmlText
} else {
fwd = escapedName + ": " + htmlText
}
editParseMode = "HTML"
} else {
fwd = formatAttribution(name, text, b.cfg.MessageNewline)
if prefix {
fwd = formatAttribution("[MAX] "+name, text, b.cfg.MessageNewline)
} else {
fwd = formatAttribution(name, text, b.cfg.MessageNewline)
}
}
// Проверяем вложения в edit — если есть медиа, используем editMessageMedia
@ -131,16 +149,16 @@ func (b *Bridge) listenMax(ctx context.Context) {
var mediaIM TGInputMedia
switch mediaType {
case "photo":
mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd, ParseMode: editParseMode}
case "video":
mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd, ParseMode: editParseMode}
case "document":
mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd, ParseMode: editParseMode}
}
if err := b.tg.EditMessageMedia(ctx, tgChatID, tgMsgID, mediaIM); err != nil {
slog.Error("MAX→TG edit media failed", "err", err, "uid", editUpd.Message.Sender.UserId)
// Fallback — отправляем как новое сообщение
go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, "", 0, 0, b.cfg.maxMaxFileBytes())
go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, editParseMode, 0, 0, b.cfg.maxMaxFileBytes())
} else {
slog.Info("MAX→TG edited media", "tgMsg", tgMsgID, "type", mediaType, "uid", editUpd.Message.Sender.UserId)
}
@ -151,7 +169,11 @@ func (b *Bridge) listenMax(ctx context.Context) {
if text == "" {
continue
}
if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, nil); err != nil {
var editOpts *SendOpts
if editParseMode != "" {
editOpts = &SendOpts{ParseMode: editParseMode}
}
if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, editOpts); err != nil {
slog.Error("MAX→TG edit failed", "err", err, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
} else {
slog.Info("MAX→TG edited", "tgMsg", tgMsgID, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
@ -910,11 +932,27 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
mediaSent := false
var qAttType, qAttURL string // для очереди при ошибке
// Определяем HTML caption если есть markups (для кросспостинга)
// Определяем HTML caption если есть markups
htmlCaption := caption
useHTML := len(body.Markups) > 0 && caption == text
useHTML := len(body.Markups) > 0
if useHTML {
htmlCaption = maxMarkupsToHTML(text, body.Markups)
htmlText := maxMarkupsToHTML(text, body.Markups)
if caption == text {
// Кросспостинг: caption = сырой текст, без атрибуции
htmlCaption = htmlText
} else {
// Bridge: caption с атрибуцией — конвертируем текст отдельно, потом строим атрибуцию
name := maxName(msgUpd)
if b.repo.HasPrefix("max", msgUpd.Message.Recipient.ChatId) {
name = "[MAX] " + name
}
escapedName := html.EscapeString(name)
if b.cfg.MessageNewline {
htmlCaption = escapedName + ":\n" + htmlText
} else {
htmlCaption = escapedName + ": " + htmlText
}
}
}
// Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia
@ -1059,10 +1097,8 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
if text == "" {
return
}
// Если есть markups и caption = оригинальный текст (кросспостинг), конвертируем в HTML
if len(body.Markups) > 0 && caption == text {
htmlText := maxMarkupsToHTML(text, body.Markups)
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlText, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID})
if useHTML {
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlCaption, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID})
} else {
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID})
}

View file

@ -89,12 +89,26 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue
}
// Текстовый edit
fwd := formatTgMessage(edited, prefix, b.cfg.MessageNewline)
if fwd == "" {
// Текстовый edit — конвертируем entities в markdown
rawText := edited.Text
editEntities := edited.Entities
if rawText == "" {
rawText = edited.Caption
editEntities = edited.CaptionEntities
}
if rawText == "" {
continue
}
mdText := tgEntitiesToMarkdown(rawText, editEntities)
name := tgName(edited)
if prefix {
name = "[TG] " + name
}
fwd := formatAttribution(name, mdText, b.cfg.MessageNewline)
m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd)
if mdText != rawText {
m.SetFormat("markdown")
}
if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil {
slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
} else {
@ -463,11 +477,19 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
if checkSize(photo.FileSize, "") {
return
}
// Конвертируем entities в markdown для caption фото
photoEntities := msg.CaptionEntities
mdCaption := tgEntitiesToMarkdown(caption, photoEntities)
// Конвертируем entities в markdown на сыром тексте (до атрибуции, иначе офсеты съезжают)
rawText := msg.Caption
if rawText == "" {
rawText = msg.Text
}
mdText := tgEntitiesToMarkdown(rawText, msg.CaptionEntities)
name := tgName(msg)
if b.repo.HasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption := formatAttribution(name, mdText, b.cfg.MessageNewline)
m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption)
if mdCaption != caption {
if mdText != rawText {
m.SetFormat("markdown")
}
if b.cfg.TgAPIURL != "" {
@ -687,6 +709,16 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
}
}
// Конвертируем TG entities в markdown на сыром тексте (до атрибуции, иначе офсеты съезжают)
rawText := msg.Text
entities := msg.Entities
if rawText == "" {
rawText = msg.Caption
entities = msg.CaptionEntities
}
mdText := tgEntitiesToMarkdown(rawText, entities)
hasFormatting := mdText != rawText
// Fallback для неудавшейся загрузки медиа
if mediaAttType == "" && msg.Text == "" {
mediaType := ""
@ -706,7 +738,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
default:
return
}
caption = caption + mediaType
mdText = mdText + mediaType
}
// Reply ID
@ -717,13 +749,11 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
}
}
// Конвертируем TG entities в markdown для MAX
entities := msg.Entities
if entities == nil {
entities = msg.CaptionEntities
name := tgName(msg)
if b.repo.HasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption := tgEntitiesToMarkdown(caption, entities)
hasFormatting := mdCaption != caption
mdCaption := formatAttribution(name, mdText, b.cfg.MessageNewline)
var mid string
var sendErr error
@ -771,14 +801,26 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
uid := tgUserID(msg)
m := maxbot.NewMessage().SetChat(maxChatID)
// Конвертируем entities в markdown на сыром тексте (до атрибуции)
rawText := msg.Caption
editEntities := msg.CaptionEntities
if rawText == "" {
rawText = msg.Text
editEntities = msg.Entities
}
mdText := tgEntitiesToMarkdown(rawText, editEntities)
name := tgName(msg)
if b.repo.HasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption := formatAttribution(name, mdText, b.cfg.MessageNewline)
m.SetText(mdCaption)
if mdText != rawText {
m.SetFormat("markdown")
}
if msg.Photo != nil {
photo := msg.Photo[len(msg.Photo)-1]
photoEntities := msg.CaptionEntities
mdCaption := tgEntitiesToMarkdown(caption, photoEntities)
m.SetText(mdCaption)
if mdCaption != caption {
m.SetFormat("markdown")
}
if b.cfg.TgAPIURL != "" {
if uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID); err == nil {
m.AddPhoto(uploaded)
@ -794,19 +836,6 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
return
}
}
} else {
// Видео, документ, анимация, голос, аудио — для edit обновляем только текст,
// т.к. замена нефотовложений через edit не поддерживается MAX API (только photo payload).
// Медиа остаётся прежним, обновляется caption.
entities := msg.CaptionEntities
if entities == nil {
entities = msg.Entities
}
mdCaption := tgEntitiesToMarkdown(caption, entities)
m.SetText(mdCaption)
if mdCaption != caption {
m.SetFormat("markdown")
}
}
if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil {