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
209 lines
6.3 KiB
Go
209 lines
6.3 KiB
Go
package main
|
||
|
||
import (
|
||
"html"
|
||
"regexp"
|
||
"strings"
|
||
|
||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||
)
|
||
|
||
func tgName(msg *TGMessage) string {
|
||
if msg.From == nil {
|
||
if msg.SenderChat != nil {
|
||
return msg.SenderChat.Title
|
||
}
|
||
return "Unknown"
|
||
}
|
||
name := msg.From.FirstName
|
||
if msg.From.LastName != "" {
|
||
name += " " + msg.From.LastName
|
||
}
|
||
return name
|
||
}
|
||
|
||
// formatAttribution собирает строку "Имя: текст" или "Имя:\nтекст" в зависимости от настройки.
|
||
func formatAttribution(name, text string, newline bool) string {
|
||
if newline {
|
||
return name + ":\n" + text
|
||
}
|
||
return name + ": " + text
|
||
}
|
||
|
||
// formatAttributionMD собирает строку с жирным именем в markdown: "**Имя**: текст".
|
||
func formatAttributionMD(name, text string, newline bool) string {
|
||
bold := "**" + name + "**"
|
||
if newline {
|
||
return bold + ":\n" + text
|
||
}
|
||
return bold + ": " + text
|
||
}
|
||
|
||
// formatAttributionHTML — имя жирным в HTML: "<b>Имя</b>: текст". Имя экранируется;
|
||
// text уже должен быть HTML (прошёл через tgEntitiesToHTML).
|
||
func formatAttributionHTML(name, text string, newline bool) string {
|
||
bold := "<b>" + html.EscapeString(name) + "</b>"
|
||
if newline {
|
||
return bold + ":\n" + text
|
||
}
|
||
return bold + ": " + text
|
||
}
|
||
|
||
// tgForwardLine — метка «↪️ Переслано из X\n» (HTML, italic) для зеркалируемых форвардов.
|
||
// "" если сообщение не форвард. Подмешивается в начало тела (после атрибуции автора).
|
||
func tgForwardLine(msg *TGMessage) string {
|
||
if msg.ForwardFrom == "" {
|
||
return ""
|
||
}
|
||
return "↪️ <i>Переслано из " + html.EscapeString(msg.ForwardFrom) + "</i>\n"
|
||
}
|
||
|
||
// tgContactText — текстовое представление контакта (sharing телефона), чтобы зеркало
|
||
// не было пустым. "" если контакта нет.
|
||
func tgContactText(msg *TGMessage) string {
|
||
if msg.Contact == nil {
|
||
return ""
|
||
}
|
||
name := strings.TrimSpace(msg.Contact.FirstName + " " + msg.Contact.LastName)
|
||
if name == "" {
|
||
name = "Контакт"
|
||
}
|
||
return "📇 " + name + "\n📞 " + msg.Contact.PhoneNumber
|
||
}
|
||
|
||
// formatTgCaption — для пересылки (текст или caption)
|
||
func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
|
||
name := tgName(msg)
|
||
text := msg.Text
|
||
if text == "" {
|
||
text = msg.Caption
|
||
}
|
||
if text == "" {
|
||
text = tgContactText(msg)
|
||
}
|
||
if prefix {
|
||
return formatAttribution("[TG] "+name, text, newline)
|
||
}
|
||
return formatAttribution(name, text, newline)
|
||
}
|
||
|
||
// formatTgMessage — для edit (полный формат)
|
||
func formatTgMessage(msg *TGMessage, prefix, newline bool) string {
|
||
name := tgName(msg)
|
||
text := msg.Text
|
||
if text == "" {
|
||
text = msg.Caption
|
||
}
|
||
if text == "" {
|
||
text = tgContactText(msg)
|
||
}
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
if prefix {
|
||
return formatAttribution("[TG] "+name, text, newline)
|
||
}
|
||
return formatAttribution(name, text, newline)
|
||
}
|
||
|
||
func maxName(upd *maxschemes.MessageCreatedUpdate) string {
|
||
name := upd.Message.Sender.Name
|
||
if name == "" {
|
||
name = upd.Message.Sender.Username
|
||
}
|
||
return name
|
||
}
|
||
|
||
// formatMaxCaption — для пересылки
|
||
func formatMaxCaption(upd *maxschemes.MessageCreatedUpdate, prefix, newline bool) string {
|
||
name := maxName(upd)
|
||
text := upd.Message.Body.Text
|
||
if prefix {
|
||
return formatAttribution("[MAX] "+name, text, newline)
|
||
}
|
||
return formatAttribution(name, text, newline)
|
||
}
|
||
|
||
// formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов).
|
||
// Конвертирует entities в HTML (format="html" в MAX), чтобы сохранить ссылки,
|
||
// форматирование, КОД и ЦИТАТЫ (MAX-markdown их не рендерит). Replacements
|
||
// применяются поверх HTML.
|
||
func formatTgCrosspostCaption(msg *TGMessage) string {
|
||
text := msg.Text
|
||
entities := msg.Entities
|
||
if text == "" {
|
||
text = msg.Caption
|
||
entities = msg.CaptionEntities
|
||
}
|
||
return collapseWhitespace(tgEntitiesToHTML(text, entities))
|
||
}
|
||
|
||
// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
|
||
func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string {
|
||
return collapseWhitespace(upd.Message.Body.Text)
|
||
}
|
||
|
||
// collapseWhitespace убирает «паддинг» из пересылаемого текста: длинные пробельные
|
||
// прогоны и лишние пустые строки (часто в постах-простынях), которые раздувают длину
|
||
// (лимит MAX ~4000) и уродуют вид. Схлопывает 2+ пробелов/табов/nbsp → 1, обрезает
|
||
// хвостовые пробелы строк, 3+ переносов → максимум одна пустая строка.
|
||
var (
|
||
reTrailSpace = regexp.MustCompile(`[ \t\x{00A0}]+\n`)
|
||
reManyBlank = regexp.MustCompile(`\n{3,}`)
|
||
reManySpace = regexp.MustCompile(`[ \t\x{00A0}]{2,}`)
|
||
)
|
||
|
||
func collapseWhitespace(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||
s = reTrailSpace.ReplaceAllString(s, "\n")
|
||
s = reManyBlank.ReplaceAllString(s, "\n\n")
|
||
s = reManySpace.ReplaceAllString(s, " ")
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует.
|
||
func mimeToFilename(base, mime string) string {
|
||
ext := ""
|
||
// sub = часть после "/" в mime type
|
||
if i := strings.Index(mime, "/"); i >= 0 {
|
||
sub := mime[i+1:]
|
||
switch sub {
|
||
case "mp4":
|
||
ext = ".mp4"
|
||
case "webm":
|
||
ext = ".webm"
|
||
case "x-matroska":
|
||
ext = ".mkv"
|
||
case "quicktime":
|
||
ext = ".mov"
|
||
case "mpeg":
|
||
ext = ".mpeg"
|
||
case "ogg":
|
||
ext = ".ogg"
|
||
case "pdf":
|
||
ext = ".pdf"
|
||
case "gif":
|
||
ext = ".gif"
|
||
default:
|
||
ext = "." + sub
|
||
}
|
||
}
|
||
return base + ext
|
||
}
|
||
|
||
// fileNameFromURL извлекает имя файла из URL, fallback "file".
|
||
func fileNameFromURL(rawURL string) string {
|
||
if idx := strings.LastIndex(rawURL, "/"); idx >= 0 {
|
||
name := rawURL[idx+1:]
|
||
if q := strings.Index(name, "?"); q >= 0 {
|
||
name = name[:q]
|
||
}
|
||
if name != "" {
|
||
return name
|
||
}
|
||
}
|
||
return "file"
|
||
}
|