max-telegram-bridge-bot/telegram.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

1835 lines
78 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"
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strconv"
"strings"
maxbot "github.com/max-messenger/max-bot-api-client-go"
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
)
func (b *Bridge) listenTelegram(ctx context.Context) {
var updates <-chan TGUpdate
if b.cfg.TgWebhookURL != "" {
whPath := b.tgWebhookPath()
whURL := strings.TrimRight(b.cfg.TgWebhookURL, "/") + whPath
if err := b.tg.SetWebhook(ctx, whURL); err != nil {
slog.Error("TG set webhook failed", "err", err)
return
}
updates = b.tg.StartWebhook(ctx, whPath)
slog.Info("TG webhook mode")
} else {
// Удаляем webhook если был, переключаемся на polling
b.tg.DeleteWebhook(ctx)
updates = b.tg.StartPolling(ctx)
slog.Info("TG polling mode")
}
for {
select {
case <-ctx.Done():
return
case update, ok := <-updates:
if !ok {
slog.Warn("TG updates channel closed")
return
}
// Обработка channel posts (crosspost forwarding only)
if update.EditedChannelPost != nil {
b.handleTgEditedChannelPost(ctx, update.EditedChannelPost)
continue
}
if update.ChannelPost != nil {
b.handleTgChannelPost(ctx, update.ChannelPost)
continue
}
// Обработка edit
if update.EditedMessage != nil {
edited := update.EditedMessage
if b.isSelfTgBot(edited.From) {
continue
}
// Модерация правок: спамер может опубликовать чистый текст (пройдёт фильтр),
// затем отредактировать его в спам. Прогоняем edit через антиспам так же, как
// новое сообщение — ДО bridge-зеркалирования и независимо от наличия связки
// (иначе в standalone-группе ниже стоит `if !linked { continue }`).
if isTgGroup(edited.Chat.Type) && edited.From != nil && !edited.IsService {
isAdmin := false
if isTgAnonymousAdmin(edited) {
isAdmin = true
} else if status, err := b.tg.GetChatMember(ctx, edited.Chat.ID, edited.From.ID); err == nil {
isAdmin = isTgAdmin(status)
}
mtext := edited.Text
if mtext == "" {
mtext = edited.Caption
}
if edited.HasExternalReply {
mtext = strings.TrimSpace(mtext + " " + edited.ExternalReplyText)
}
if b.moderateGroupMessage(ctx, GroupMessage{
Platform: "tg", ChatID: edited.Chat.ID, UserID: edited.From.ID,
TgMsgID: edited.MessageID, Text: mtext, HasLink: tgMsgHasLink(edited), IsAdmin: isAdmin,
ExtReply: edited.HasExternalReply,
}) {
continue
}
}
var maxChatID int64
var linked bool
maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID)
if !linked {
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
}
if !linked {
continue
}
hasMedia := edited.Photo != nil || edited.Video != nil || edited.Document != nil ||
edited.Animation != nil || edited.Sticker != nil || edited.Voice != nil || edited.Audio != nil
maxMsgID, hasMapping := b.repo.LookupMaxMsgID(edited.Chat.ID, edited.MessageID)
// Если маппинг не найден и есть медиа — отправляем как новое сообщение (fallback)
if hasMedia && !hasMapping {
prefix := b.hasPrefix("tg", edited.Chat.ID)
caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline)
go b.forwardTgToMax(ctx, edited, maxChatID, caption, false, false)
continue
}
if !hasMapping {
continue
}
prefix := b.hasPrefix("tg", edited.Chat.ID)
if hasMedia {
// Edit с медиа — редактируем сообщение в MAX с новым вложением
caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline)
go b.editTgMediaInMax(ctx, edited, maxChatID, maxMsgID, caption)
continue
}
// Текстовый edit — конвертируем entities в markdown
rawText := edited.Text
editEntities := edited.Entities
if rawText == "" {
rawText = edited.Caption
editEntities = edited.CaptionEntities
}
if rawText == "" {
continue
}
mdText := tgEntitiesToHTML(rawText, editEntities)
name := tgName(edited)
if prefix {
name = "[TG] " + name
}
fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd)
m.SetFormat("html")
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
} else {
slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
}
continue
}
// Обработка inline-кнопок (crosspost management)
if update.CallbackQuery != nil {
b.handleTgCallback(ctx, update.CallbackQuery)
continue
}
if update.Message == nil {
continue
}
msg := update.Message
// Обработка миграции группы в supergroup — обновляем chat ID в базе
if msg.MigrateToChatID != 0 {
slog.Info("TG chat migrated to supergroup", "old", msg.Chat.ID, "new", msg.MigrateToChatID)
if err := b.repo.MigrateTgChat(msg.Chat.ID, msg.MigrateToChatID); err != nil {
slog.Error("MigrateTgChat failed", "err", err)
}
continue
}
text := strings.TrimSpace(msg.Text)
// Убираем @botname из команд: /bridge@MaxTelegramBridgeBot → /bridge
if strings.HasPrefix(text, "/") {
if at := strings.Index(text, "@"); at > 0 {
rest := text[at:]
if sp := strings.IndexByte(rest, ' '); sp > 0 {
text = text[:at] + rest[sp:]
} else {
text = text[:at]
}
}
}
slog.Debug("TG msg received", "uid", tgUserID(msg), "chat", msg.Chat.ID, "type", msg.Chat.Type)
// Запоминаем юзера при личном сообщении
if msg.Chat.Type == "private" && msg.From != nil {
b.repo.TouchUser(msg.From.ID, "tg", msg.From.UserName, msg.From.FirstName)
}
// Опциональные аддоны (если подключены build-тегом) первыми получают
// личные сообщения и форварды из каналов. Если аддон взял сообщение в
// работу — бридж дальше не обрабатывает. В публичной сборке addon == nil.
if msg.Chat.Type == "private" && b.addon != nil && msg.From != nil {
// Пользователь выбрал группу нативной кнопкой (chat_shared) — запоминаем
// её и отдаём аддону (включение антиспама из лички без захода в группу).
if msg.ChatShared != nil {
b.noteBotChat("tg", msg.ChatShared.ChatID, msg.ChatShared.Title, "supergroup")
if b.chatShared(ctx, msg.From.ID, msg.ChatShared.RequestID, msg.ChatShared.ChatID, msg.ChatShared.Title) {
continue
}
}
if msg.ForwardOriginChat != nil && msg.ForwardOriginChat.Type == "channel" {
if b.addon.HandleDMForward(ctx, msg.From.ID, msg.Chat.ID, msg.ForwardOriginChat.ID, msg.ForwardOriginChat.Title, msg.ForwardOriginMsgID) {
continue
}
}
if b.addon.HandleDMCommand(ctx, msg.From.ID, msg.Chat.ID, text) {
continue
}
}
if text == "/whoami" {
b.tg.SendMessage(ctx, msg.Chat.ID,
"MaxTelegramBridgeBot — мост между Telegram и MAX.\n"+
"Автор: Andrey Lugovskoy (@BEARlogin)\n"+
"Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n"+
"Лицензия: CC BY-NC 4.0", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if text == "/start" || text == "/help" {
if ch := customHelp(); ch != "" {
b.tg.SendMessage(ctx, msg.Chat.ID, ch, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
intro, kb := b.tgStartMenu()
b.tg.SendMessage(ctx, msg.Chat.ID, intro,
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID, ReplyMarkup: kb})
continue
}
// Обработка ввода замены (если юзер в режиме ожидания)
if msg.Chat.Type == "private" && msg.From != nil && !strings.HasPrefix(text, "/") {
if w, ok := b.getReplWait(msg.From.ID); ok {
b.clearReplWait(msg.From.ID)
rule, valid := parseReplacementInput(text)
if !valid {
b.tg.SendMessage(ctx, msg.Chat.ID, "Не получилось разобрать. Нужна одна строка с вертикальной чертой «|»:\n<code>что заменить | на что</code>\n\nНапример: <code>наш Телеграм | наш канал в MAX</code>\n\nПопробуйте ещё раз через кнопку «🔄 Замены».", &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
rule.Target = w.target
repl := b.repo.GetCrosspostReplacements(w.maxChatID)
if w.direction == "tg>max" {
repl.TgToMax = append(repl.TgToMax, rule)
} else {
repl.MaxToTg = append(repl.MaxToTg, rule)
}
if err := b.repo.SetCrosspostReplacements(w.maxChatID, repl); err != nil {
slog.Error("save replacements failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Ошибка сохранения.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
ruleType := "строка"
if rule.Regex {
ruleType = "regex"
}
dirLabel := "TG → MAX"
if w.direction == "max>tg" {
dirLabel = "MAX → TG"
}
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Замена добавлена (%s, %s):\n<code>%s</code> → <code>%s</code>", dirLabel, ruleType, rule.From, rule.To),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
}
// /link <код> — привязать MAX-аккаунт к этому TG-аккаунту (код из MAX-кабинета)
if msg.Chat.Type == "private" && strings.HasPrefix(text, "/link") && msg.From != nil {
code := strings.TrimSpace(strings.TrimPrefix(text, "/link"))
if code == "" {
b.tg.SendMessage(ctx, msg.Chat.ID, "Чтобы связать аккаунты: в мини-аппе (кабинете) нажмите «Привязать Telegram», получите код и пришлите его сюда: <code>/link КОД</code>", &SendOpts{ParseMode: "HTML"})
} else if b.redeemLinkCode(ctx, code, msg.From.ID) {
b.tg.SendMessage(ctx, msg.Chat.ID, "✅ Аккаунты связаны. Теперь это один аккаунт в боте — и из MAX, и из Telegram.", nil)
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "❌ Код неверный или истёк (живёт 10 минут). Сгенерируйте новый в кабинете.", nil)
}
continue
}
// /crosspost в личке TG — показать список связок
if msg.Chat.Type == "private" && text == "/crosspost" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) {
continue
}
links := b.repo.ListCrossposts(msg.From.ID)
if len(links) == 0 {
b.tg.SendMessage(ctx, msg.Chat.ID,
"Нет активных связок.\n\nНастройка: перешлите пост из TG-канала сюда, затем в MAX-боте /crosspost <ID>", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
for _, l := range links {
kb := tgCrosspostKeyboard(l.Direction, l.MaxChatID, b.repo.GetCrosspostSyncEdits(l.MaxChatID))
tgTitle := b.tgChatTitle(ctx, l.TgChatID)
statusText := tgCrosspostStatusText(tgTitle, l.Direction)
if tgTitle == "" {
statusText += fmt.Sprintf("\nTG: %d ↔ MAX: %d", l.TgChatID, l.MaxChatID)
} else {
statusText += fmt.Sprintf("\nTG: «%s» (%d)\nMAX: %d", tgTitle, l.TgChatID, l.MaxChatID)
}
b.tg.SendMessage(ctx, msg.Chat.ID, statusText, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID})
}
}
continue
}
// Пересланное сообщение из канала → показать ID или управление (только в личке)
if msg.Chat.Type == "private" && msg.ForwardOriginChat != nil && msg.ForwardOriginChat.Type == "channel" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) {
continue
}
channelID := msg.ForwardOriginChat.ID
channelTitle := msg.ForwardOriginChat.Title
// Запоминаем TG user ID для этого канала (для owner при pairing)
b.cpTgOwnerMu.Lock()
b.cpTgOwner[channelID] = msg.From.ID
b.cpTgOwnerMu.Unlock()
slog.Info("TG crosspost forward", "tgUser", msg.From.ID, "tgChannel", channelID)
// Проверяем, уже связан ли канал
if maxChatID, direction, ok := b.repo.GetCrosspostMaxChat(channelID); ok {
text := tgCrosspostStatusText(channelTitle, direction)
kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
b.tg.SendMessage(ctx, msg.Chat.ID, text, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID})
continue
}
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("TG-канал «%s»\nID: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.%s", channelTitle, channelID, channelID, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
// Проверка прав админа в группах
isGroup := isTgGroup(msg.Chat.Type)
if isGroup {
b.noteBotChat("tg", msg.Chat.ID, msg.Chat.Title, "group")
}
isAdmin := false
botNotAdmin := false
if isGroup {
if isTgAnonymousAdmin(msg) {
// Владелец/админ с "Remain anonymous" — шлёт от имени группы
isAdmin = true
} else if msg.From != nil {
status, err := b.tg.GetChatMember(ctx, msg.Chat.ID, msg.From.ID)
if err != nil {
slog.Warn("TG getChatMember failed", "err", err, "chat", msg.Chat.ID, "user", msg.From.ID, "chatType", msg.Chat.Type)
if strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") {
botNotAdmin = true
}
} else {
slog.Debug("TG getChatMember ok", "chat", msg.Chat.ID, "user", msg.From.ID, "status", status)
isAdmin = isTgAdmin(status)
}
}
}
adminDeniedText := "Эта команда доступна только админам группы."
if botNotAdmin {
adminDeniedText = "Бот не может проверить ваши права — сделайте бота админом группы и повторите команду."
}
// /thread — установить/сбросить топик по умолчанию
if text == "/thread" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if _, ok := b.repo.GetMaxChat(msg.Chat.ID); !ok {
b.tg.SendMessage(ctx, msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if msg.MessageThreadID != 0 {
b.repo.SetTgThreadID(msg.Chat.ID, msg.MessageThreadID)
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Топик по умолчанию установлен (thread %d). Сообщения из MAX будут приходить сюда.", msg.MessageThreadID),
&SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.repo.SetTgThreadID(msg.Chat.ID, 0)
b.tg.SendMessage(ctx, msg.Chat.ID, "Топик сброшен. Сообщения из MAX будут приходить в основной чат.", &SendOpts{})
}
slog.Info("thread set", "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID, "uid", tgUserID(msg))
continue
}
// /bridge prefix on/off
if text == "/bridge prefix on" || text == "/bridge prefix off" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
on := text == "/bridge prefix on"
if b.repo.SetPrefix("tg", msg.Chat.ID, on) {
if on {
b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] включён.", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] выключен.", &SendOpts{ThreadID: msg.MessageThreadID})
}
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// /bridge_update — записать себя владельцем уже связанной группы,
// чтобы она появилась в веб-кабинете (для старых связок без владельца).
if text == "/bridge_update" {
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if _, linked := b.repo.GetMaxChat(msg.Chat.ID); !linked {
b.tg.SendMessage(ctx, msg.Chat.ID, "Эта группа не связана с MAX. Сначала свяжите её через /bridge.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if b.repo.SetPairOwner("tg", msg.Chat.ID, tgUserID(msg)) {
b.tg.SendMessage(ctx, msg.Chat.ID, "Готово ✅ Связка обновлена — теперь группа доступна в веб-кабинете.", &SendOpts{ThreadID: msg.MessageThreadID})
slog.Info("bridge_update owner set", "platform", "tg", "chat", msg.Chat.ID, "user", tgUserID(msg))
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось обновить связку.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// /bridge или /bridge <key>
if text == "/bridge" || strings.HasPrefix(text, "/bridge ") {
slog.Info("TG /bridge command", "chat", msg.Chat.ID, "chatType", msg.Chat.Type, "chatTitle", msg.Chat.Title, "user", tgUserID(msg), "isGroup", isGroup, "isAdmin", isAdmin, "text", text)
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
// /bridge без ключа — если чат уже связан, не создавать новый ключ
if key == "" {
if maxID, linked := b.repo.GetMaxChat(msg.Chat.ID); linked {
var txt string
if isGroup {
txt = fmt.Sprintf("Эта группа уже связана с MAX (ID <code>%d</code>).\n\n/unbridge — удалить связку.", maxID)
} else {
txt = fmt.Sprintf("Этот личный чат уже связан с MAX (ID <code>%d</code>).\n\nЧтобы связать <b>группу</b> — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\n/unbridge — удалить связку этого личного чата.", maxID)
}
b.tg.SendMessage(ctx, msg.Chat.ID, txt, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
if !isGroup {
b.tg.SendMessage(ctx, msg.Chat.ID,
"Чтобы связать группу — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\nЕсли хотите связать этот личный чат с MAX-пользователем — введите ключ от него: <code>/bridge &lt;ключ&gt;</code>.",
&SendOpts{ParseMode: "HTML"})
continue
}
}
var bridgeUserID int64
if msg.From != nil {
bridgeUserID = msg.From.ID
}
paired, generatedKey, err := b.repo.Register(key, "tg", msg.Chat.ID, bridgeUserID)
if err != nil {
slog.Error("register failed", "err", err)
continue
}
if paired {
b.tg.SendMessage(ctx, msg.Chat.ID, "Связано! Сообщения теперь пересылаются.", &SendOpts{ThreadID: msg.MessageThreadID})
b.repo.SetTgThreadID(msg.Chat.ID, msg.MessageThreadID) // 0 = no topics
slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key)
} else if generatedKey != "" {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nДобавьте MAX-бота в нужную MAX-группу, сделайте его <b>администратором с правом «Доступ к сообщениям»</b> (читать все сообщения — иначе бот не увидит команду), и отправьте <b>в ней</b> (не в ЛС бота):\n<code>/bridge %s</code>\n\nСсылка на MAX-бота (для добавления в группу): %s%s", generatedKey, generatedKey, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey)
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Ключ не найден или чат той же платформы.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
if text == "/unbridge" {
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if b.repo.Unpair("tg", msg.Chat.ID) {
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// Пауза/возобновление связки групп (не удаляя её).
if text == "/pause" || text == "/unpause" {
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID)
if !linked {
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан (пауза — для связки групп).", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
pause := text == "/pause"
if err := b.repo.SetPairPaused(msg.Chat.ID, maxChatID, pause); err != nil {
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось изменить паузу.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if pause {
b.tg.SendMessage(ctx, msg.Chat.ID, "⏸ Связка на паузе — сообщения не зеркалятся (в обе стороны). Возобновить: /unpause", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "▶️ Связка возобновлена — пересылка снова работает.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// /thread_bridge — связать конкретный тред с отдельным MAX-чатом
if text == "/thread_bridge" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if !isGroup {
b.tg.SendMessage(ctx, msg.Chat.ID, "Команда работает только в форум-группах.", nil)
continue
}
if !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
// General-топик форума имеет thread_id=0 — это валидный тред, связываем как
// и любой другой (id=0). Маршрутизация ниже проверяет thread-bridge для всех id.
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
key, err := b.repo.StartThreadBridge(msg.Chat.ID, msg.MessageThreadID)
if err != nil {
slog.Error("StartThreadBridge failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось создать ключ.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s%s", key, key, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
if sendErr != nil {
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID)
}
slog.Info("thread-bridge pending", "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID, "key", key)
continue
}
// /thread_unbridge — удалить связку конкретного треда
if text == "/thread_unbridge" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот тред не связан.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// Группа обсуждения канала: ловим авто-форвард поста (строим маппинг
// пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app).
if isGroup {
// Вступления — капча/анти-рейд (аддон сам решает по конфигу).
if len(msg.NewChatMembers) > 0 {
for _, nm := range msg.NewChatMembers {
if !b.isSelfTgBot(&nm) {
b.memberJoined(ctx, "tg", msg.Chat.ID, nm.ID, strings.TrimSpace(nm.FirstName+" "+nm.LastName), nm.UserName, nm.IsBot)
}
}
// Удаление служебного «вошёл», если включено владельцем (после капчи/рейда).
if b.delServiceMsgs("tg", msg.Chat.ID) {
b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID)
}
continue // служебное сообщение о вступлении — дальше не обрабатываем
}
// Прочие служебные сообщения (вышел/смена названия/фото/закреп). Удаляем,
// если включено — здесь, внутри isGroup, чтобы работало и в standalone-группах
// (ниже стоит `if !linked continue`, который их бы не пропустил к общему хендлеру).
if msg.IsService {
if b.delServiceMsgs("tg", msg.Chat.ID) {
b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID)
}
continue
}
if msg.IsAutomaticForward {
// Авто-форвард поста канала в группу обсуждения (корень треда) — НИКОГДА
// не модерируем и не зеркалим. Маппинг пост↔тред сохраняем, только если
// есть данные origin (иначе просто пропускаем — это контент канала, не спам).
if msg.ForwardOriginChat != nil && msg.ForwardOriginMsgID != 0 {
if err := b.repo.SaveDiscussionMessage(msg.ForwardOriginChat.ID, msg.ForwardOriginMsgID, msg.Chat.ID, msg.MessageID); err != nil {
slog.Warn("save discussion map failed", "err", err)
}
}
continue
}
// Антиспам/команда — ДО ингеста коммента. Иначе в группе обсуждения канала
// /antispam и модерация комментов не срабатывают (сообщение уходит в continue ниже).
// SenderChat != nil — сообщение «от имени канала» (пост связанного канала,
// репост канала): это контент канала, а не юзер-спам — не модерируем.
if msg.From != nil && !msg.IsService && msg.SenderChat == nil {
mtext := msg.Text
if mtext == "" {
mtext = msg.Caption
}
hasLink := tgMsgHasLink(msg)
// Спам через цитату чужого канала (external_reply): чистый текст —
// наживка, пейлоад в цитате. Подмешиваем текст цитаты в анализ; сам
// факт external_reply — отдельный сильный сигнал (ExtReply).
if msg.HasExternalReply {
mtext = strings.TrimSpace(mtext + " " + msg.ExternalReplyText)
}
if b.moderateGroupMessage(ctx, GroupMessage{
Platform: "tg", ChatID: msg.Chat.ID, UserID: msg.From.ID,
TgMsgID: msg.MessageID, Text: mtext, HasLink: hasLink, IsAdmin: isAdmin,
ExtReply: msg.HasExternalReply,
}) {
continue
}
// Модерация картинки (QR + vision). Качаем байты только если антиспам
// в чате включён (иначе не трогаем фото впустую).
if len(msg.Photo) > 0 && b.wantImageCheck("tg", msg.Chat.ID) {
if data, mime, err := b.tgPhotoBytes(ctx, msg.Photo); err == nil && len(data) > 0 {
if b.moderateImage(ctx, "tg", msg.Chat.ID, msg.From.ID, msg.MessageID, "", data, mime, isAdmin) {
continue
}
} else if err != nil {
slog.Warn("antispam: photo download failed", "err", err, "chat", msg.Chat.ID)
}
}
}
if msg.MessageThreadID != 0 && msg.From != nil && !b.isSelfTgBot(msg.From) && !msg.IsService {
chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID)
slog.Info("discussion reply seen", "discChat", msg.Chat.ID, "thread", msg.MessageThreadID, "mapped", ok, "channelPost", fmt.Sprintf("%d_%d", chCh, chMsg))
if ok {
dtext := msg.Text
if dtext == "" {
dtext = msg.Caption
}
if dtext != "" {
// reply на конкретный коммент (а не на сам пост) — если
// reply_to не равен корню треда (авто-форварду поста).
replyToTg := 0
if msg.ReplyToMessage != nil && msg.ReplyToMessage.MessageID != msg.MessageThreadID {
replyToTg = msg.ReplyToMessage.MessageID
}
b.ingestDiscussionComment(ctx, chCh, chMsg, msg.From, dtext, msg.MessageID, replyToTg)
}
continue // коммент обсуждения уже в TG — в MAX не зеркалим
}
}
}
// (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения)
// Пересылка. Приоритет у thread-bridge (вкл. General, thread_id=0): если этот
// тред связан отдельно — шлём в его MAX-чат, иначе фоллбэк на групповую связку.
var maxChatID int64
var linked bool
maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID)
if !linked {
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
}
if !linked {
continue
}
if b.isSelfTgBot(msg.From) {
continue
}
// Служебные сообщения (вступил/вышел/смена названия/закреп) не зеркалим —
// иначе в MAX летит пустое сообщение.
if msg.IsService {
continue
}
prefix := b.hasPrefix("tg", msg.Chat.ID)
caption := formatTgCaption(msg, prefix, b.cfg.MessageNewline)
// Проверяем anti-loop
checkText := msg.Text
if checkText == "" {
checkText = msg.Caption
}
if strings.HasPrefix(checkText, "[MAX]") || strings.HasPrefix(checkText, "[TG]") {
continue
}
// Media group (альбом) — буферизуем и отправляем вместе
if msg.MediaGroupID != "" {
videoID := ""
if msg.Video != nil {
videoID = msg.Video.FileID
}
go b.bufferMediaGroup(ctx, msg.MediaGroupID, mediaGroupItem{
photoSizes: msg.Photo,
videoFileID: videoID,
caption: caption,
replyToMsg: msg.ReplyToMessage,
entities: msg.CaptionEntities,
msg: msg,
})
continue
}
go b.forwardTgToMax(ctx, msg, maxChatID, caption, false, false)
}
}
}
func tgUserID(msg *TGMessage) int64 {
if msg.From != nil {
return msg.From.ID
}
return 0
}
// forwardTgToMax пересылает TG-сообщение (текст/медиа) в MAX-чат.
// Если isCrosspost=true, caption используется как финальный текст (с заменами, без атрибуции).
// tgMsgHasLink — есть ли в сообщении ссылка/упоминание (по entities). Для правила
// «задержка ссылок новичкам» в антиспаме.
func tgMsgHasLink(msg *TGMessage) bool {
for _, e := range append(append([]Entity{}, msg.Entities...), msg.CaptionEntities...) {
switch e.Type {
case "url", "text_link", "mention", "text_mention":
return true
}
}
return false
}
// alreadyDeliveredToMax — для этого TG-сообщения уже есть сохранённый MAX-маппинг,
// т.е. оно уже доставлено. Защита от дублей при повторной обработке (вебхук-
// редоставка, реплей очереди после рестарта). Персистентно — переживает рестарт.
func (b *Bridge) alreadyDeliveredToMax(tgChatID int64, tgMsgID int) bool {
if tgMsgID == 0 {
return false
}
mid, ok := b.repo.LookupMaxMsgID(tgChatID, tgMsgID)
return ok && mid != ""
}
func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string, isCrosspost bool, bypassScreen bool) {
// Дедуп: если это сообщение уже доставлено в MAX — не отправляем повторно.
if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) {
slog.Info("skip duplicate TG→MAX", "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "crosspost", isCrosspost)
return
}
if b.cbBlocked(maxChatID) {
return
}
// Пауза связки — временно не пересылаем (связку не удаляем). Возобновить: /unpause.
if isCrosspost {
if b.repo.CrosspostPaused(maxChatID) {
return
}
} else if b.repo.PairPaused(msg.Chat.ID, maxChatID) {
return
}
// Дуал-бот: выбираем бота для этого чата один раз и пробрасываем токен в ctx —
// все аплоады/отправки этого релея пойдут одним и тем же ботом (тем, кто в чате).
ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID))
mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал)
// Relay-гейт ТОЛЬКО для бридж-ГРУПП: там бот постит сообщение от своего имени и
// получает страйк за спам/запрещёнку. Каналы (даже связанные через /bridge) и
// кросспосты НЕ трогаем — там бот не отправитель. Проверка синхронная, до доставки.
if !isCrosspost && !bypassScreen && isTgGroup(msg.Chat.Type) {
// Скриним ТОЛЬКО тело сообщения, без форматированного caption (он содержит имя
// автора). Вычурные ники (псевдо-кириллица, эмодзи) травили mixed-script/emoji-junk
// и блокировали легит-сообщения — имя автора к контенту отношения не имеет.
screenText := msg.Text
if msg.Caption != "" {
if screenText != "" {
screenText += "\n"
}
screenText += msg.Caption
}
if block, reason := b.screenRelay(ctx, "tg", msg.Chat.ID, tgUserID(msg), screenText); block {
slog.Warn("relay blocked TG→MAX (group): prohibited/spam",
"tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID, "reason", reason)
// Не дропаем тихо: кладём в очередь модерации (карточка с кнопками в чате).
// payload — сериализованное сообщение для ре-релея на «Пропустить» (file_id внутри).
payload, _ := json.Marshal(msg)
b.holdForModeration(ctx, "tg", msg.Chat.ID, strconv.Itoa(msg.MessageID), maxChatID, tgUserID(msg), tgName(msg), screenText, caption, reason, string(payload))
return
}
}
uid := tgUserID(msg)
// Кнопка мини-аппа под кросспостом (например, комментарии) — спрашиваем аддон.
// Прикрепляется к самому сообщению при отправке (не отдельным сообщением).
var openApp *maxOpenApp
if isCrosspost {
openApp = b.crosspostOpenApp(ctx, msg.Chat.ID, msg.MessageID, maxChatID)
}
// checkSize returns true and sends warning if file exceeds TG_MAX_FILE_SIZE_MB limit.
// fileSize=0 means the size is unknown (old TG messages may omit it) — we skip the check.
checkSize := func(fileSize int, fileName string) bool {
limit := b.cfg.TgMaxFileSizeMB
if limit <= 0 || fileSize <= 0 || fileSize <= limit*1024*1024 {
return false
}
warn := fmt.Sprintf("⚠️ Файл слишком большой для пересылки (%s). Максимальный размер файла %d МБ.",
formatFileSize(fileSize), limit)
if fileName != "" {
warn = fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.",
fileName, formatFileSize(fileSize), limit)
}
b.notifyTgUser(ctx, msg, maxChatID, warn, isCrosspost)
return true
}
// Определяем медиа
var mediaToken string
var mediaAttType string // "video", "file", "audio"
if msg.Photo != nil {
photo := msg.Photo[len(msg.Photo)-1]
if checkSize(photo.FileSize, "") {
return
}
// Конвертируем entities в markdown на сыром тексте (до атрибуции, иначе офсеты съезжают)
var mdCaption string
if isCrosspost {
// Кросспостинг: caption уже содержит замены, без атрибуции
mdCaption = caption
} else {
rawText := msg.Caption
if rawText == "" {
rawText = msg.Text
}
mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities)
if fl := tgForwardLine(msg); fl != "" {
mdText = fl + mdText
}
name := tgName(msg)
if b.hasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption = formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
}
m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption)
// Caption всегда markdown — и для bridge (с **жирной** атрибуцией),
// и для crosspost (formatTgCrosspostCaption уже сконвертил entities).
// При пустом тексте MAX отвергает payload с format — пропускаем.
if mdCaption != "" {
m.SetFormat("html")
}
if kb := b.openAppKeyboard(openApp); kb != nil {
m.AddKeyboard(kb)
}
if b.cfg.TgAPIURL != "" {
// Custom TG API — MAX не может скачать по URL, скачиваем и загружаем через reader
if uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID); err == nil {
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
if msg.ReplyToMessage != nil {
if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok {
m.SetReply(mdCaption, maxReplyID)
}
}
slog.Info("TG→MAX sending photo", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
result, err := mc.Messages.SendWithResult(ctx, m)
if err != nil {
slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
if isChatUnavailable(err.Error()) {
b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM
} else if b.cbFail(maxChatID) {
b.notifyTgUser(ctx, msg, maxChatID,
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
}
} else {
b.cbSuccess(maxChatID)
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
}
return
} else if msg.Animation != nil {
// GIF в Telegram — это mp4 в поле Animation
name := "animation.mp4"
if msg.Animation.FileName != "" {
name = msg.Animation.FileName
}
if checkSize(msg.Animation.FileSize, name) {
return
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Animation.FileID, maxschemes.VIDEO, name); err == nil {
mediaToken = uploaded.Token
mediaAttType = "video"
} else {
slog.Error("TG→MAX gif upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX", name), err), isCrosspost)
return
}
} else if msg.Sticker != nil {
// Стикеры: обычные — WebP (фото), анимированные — TGS/WEBM
if msg.Sticker.IsAnimated {
if checkSize(msg.Sticker.FileSize, "sticker.webm") {
return
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Sticker.FileID, maxschemes.FILE, "sticker.webm"); err == nil {
mediaToken = uploaded.Token
mediaAttType = "video"
} else {
slog.Error("TG→MAX sticker upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
} else {
// Обычный стикер WebP → отправляем как фото
if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil {
if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption)
m.AddPhoto(uploaded)
if msg.ReplyToMessage != nil {
if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok {
m.SetReply(caption, maxReplyID)
}
}
if kb := b.openAppKeyboard(openApp); kb != nil {
m.AddKeyboard(kb)
}
slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID)
result, err := mc.Messages.SendWithResult(ctx, m)
if err != nil {
slog.Error("TG→MAX sticker send failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
} else {
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
}
return
} else {
slog.Error("TG→MAX sticker photo upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
} else {
slog.Error("TG→MAX sticker getFileURL failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
}
} else if msg.Video != nil {
name := "video.mp4"
if msg.Video.FileName != "" {
name = msg.Video.FileName
}
if checkSize(msg.Video.FileSize, name) {
return
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Video.FileID, maxschemes.VIDEO, name); err == nil {
mediaToken = uploaded.Token
mediaAttType = "video"
} else {
slog.Error("TG→MAX video upload failed", "err", err, "fileSizeBytes", msg.Video.FileSize, "fileSizeMB", msg.Video.FileSize/1048576, "name", name)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX", name), err), isCrosspost)
// Видео не залилось — не блокируем отправку текста/подписи, fallback ниже подмешает [Видео].
}
} else if msg.VideoNote != nil {
if checkSize(msg.VideoNote.FileSize, "circle.mp4") {
return
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.VideoNote.FileID, maxschemes.VIDEO, "circle.mp4"); err == nil {
mediaToken = uploaded.Token
mediaAttType = "video"
} else {
slog.Error("TG→MAX video note upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить кружок в MAX", err), isCrosspost)
return
}
} else if msg.Document != nil {
name := msg.Document.FileName
uploadType := maxschemes.FILE
attType := "file"
// Документ с video MIME → загружаем как видео
if strings.HasPrefix(msg.Document.MimeType, "video/") {
uploadType = maxschemes.VIDEO
attType = "video"
if name == "" {
name = mimeToFilename("video", msg.Document.MimeType)
}
}
if name == "" {
name = mimeToFilename("document", msg.Document.MimeType)
}
if checkSize(msg.Document.FileSize, name) {
return
}
// Pre-check расширения до отправки на CDN (если whitelist задан)
if b.cfg.MaxAllowedExts != nil && attType == "file" {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if _, ok := b.cfg.MaxAllowedExts[ext]; !ok {
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), isCrosspost)
return
}
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Document.FileID, uploadType, name); err == nil {
mediaToken = uploaded.Token
mediaAttType = attType
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), isCrosspost)
return
}
slog.Error("TG→MAX file upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID,
uploadErrMsg(fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX", name), err), isCrosspost)
return
}
} else if msg.Voice != nil {
if checkSize(msg.Voice.FileSize, "voice.ogg") {
return
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Voice.FileID, maxschemes.AUDIO, "voice.ogg"); err == nil {
mediaToken = uploaded.Token
mediaAttType = "audio"
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name), isCrosspost)
return
}
slog.Error("TG→MAX voice upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить голосовое сообщение в MAX", err), isCrosspost)
return
}
} else if msg.Audio != nil {
name := "audio.mp3"
if msg.Audio.FileName != "" {
name = msg.Audio.FileName
}
if checkSize(msg.Audio.FileSize, name) {
return
}
// Pre-check расширения до отправки на CDN (если whitelist задан)
if b.cfg.MaxAllowedExts != nil {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if _, ok := b.cfg.MaxAllowedExts[ext]; !ok {
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), isCrosspost)
return
}
}
if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Audio.FileID, maxschemes.FILE, name); err == nil {
mediaToken = uploaded.Token
mediaAttType = "file"
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), isCrosspost)
return
}
slog.Error("TG→MAX audio upload failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX", name), err), isCrosspost)
return
}
}
// Формируем текст: для crosspost берём caption как есть (с заменами, без атрибуции),
// для bridge — конвертируем entities на сыром тексте, потом добавляем атрибуцию
var mdText string
if isCrosspost {
mdText = caption
} else {
rawText := msg.Text
entities := msg.Entities
if rawText == "" {
rawText = msg.Caption
entities = msg.CaptionEntities
}
mdText = tgEntitiesToHTML(rawText, entities)
}
// Контакт (sharing телефона) — текста нет, иначе зеркало пустое.
if mdText == "" && msg.Contact != nil {
mdText = tgContactText(msg)
}
// Метка репоста: «↪️ Переслано из X» перед телом.
if fl := tgForwardLine(msg); fl != "" {
mdText = fl + mdText
}
// 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:
mediaType = "[Видео]"
case msg.VideoNote != nil:
mediaType = "[Кружок]"
case msg.Document != nil:
mediaType = "[Файл]"
case msg.Voice != nil:
mediaType = "[Голосовое]"
case msg.Audio != nil:
mediaType = "[Аудио]"
case msg.Sticker != nil:
mediaType = "[Стикер]"
case msg.Animation != nil:
mediaType = "[GIF]"
case msg.Photo != nil:
mediaType = "[Фото]"
}
if mdText != "" {
mdText = mdText + "\n" + mediaType
} else {
mdText = mediaType
}
}
// Reply ID
var replyTo string
if msg.ReplyToMessage != nil {
if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok {
replyTo = maxReplyID
}
}
var mdCaption string
if isCrosspost {
mdCaption = mdText
} else {
name := tgName(msg)
if b.hasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption = formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
}
// Если для этого чата уже есть сообщения в очереди — не отправляем напрямую,
// чтобы не нарушить порядок. Сразу ставим в очередь.
// Caption для crosspost уже содержит markdown (formatTgCrosspostCaption),
// так что формат одинаков для обоих режимов.
format := "html"
if b.hasPendingForChat("tg2max", maxChatID) {
slog.Info("TG→MAX queued (pending exists)", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format)
return
}
var mid string
var sendErr error
if mediaAttType != "" {
slog.Info("TG→MAX sending direct", "type", mediaAttType, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
mid, sendErr = b.sendMaxDirectFormattedKb(ctx, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format, openApp)
} else {
slog.Info("TG→MAX sending", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
mid, sendErr = b.sendMaxDirectFormattedKb(ctx, maxChatID, mdCaption, "", "", replyTo, format, openApp)
}
if sendErr != nil {
errStr := sendErr.Error()
slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
b.noteMaxSendErr(errStr) // глобальный бан аккаунта MAX → глушим уведомления
// chat.not.found / chat.denied — перманентно: бота нет в MAX-группе или он не
// админ. НЕ ретраим и сообщаем владельцу ТОЧНО (а не «MAX недоступен/в очереди»).
notInChat := strings.Contains(errStr, "chat.not.found") || strings.Contains(errStr, "chat.denied")
// 403/404 — permanent error, не ретраим
if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") {
b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format)
}
if notInChat {
// Бот недоступен в MAX-чате (удалён/не админ) — ставим связку на паузу
// и DM-им владельцу (один раз, по переходу unpaused→paused).
b.cbPermanentFail(ctx, maxChatID)
} else if b.cbFail(maxChatID) {
b.notifyTgUser(ctx, msg, maxChatID,
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost)
}
} else {
b.cbSuccess(maxChatID)
slog.Info("TG→MAX sent", "mid", mid, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid, msg.MessageThreadID)
}
}
// editTgMediaInMax редактирует сообщение с медиа в MAX (TG→MAX edit с вложением).
func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID int64, maxMsgID string, caption string) {
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 := tgEntitiesToHTML(rawText, editEntities)
name := tgName(msg)
if b.hasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name
}
mdCaption := formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
m.SetText(mdCaption)
m.SetFormat("html")
if msg.Photo != nil {
photo := msg.Photo[len(msg.Photo)-1]
if b.cfg.TgAPIURL != "" {
if uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID); err == nil {
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось обновить фото в MAX", err), nil)
return
}
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось обновить фото в MAX", err), nil)
return
}
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось обновить фото в MAX", err), nil)
return
}
} else if msg.Video != nil {
// MAX SDK EditMessage без вложений стирает медиа на стороне MAX. Перезаливаем видео.
name := "video.mp4"
if msg.Video.FileName != "" {
name = msg.Video.FileName
}
uploaded, err := b.uploadTgMediaToMax(ctx, msg.Video.FileID, maxschemes.VIDEO, name)
if err != nil {
slog.Error("TG→MAX edit video upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось обновить видео \"%s\" в MAX", name), err), nil)
return
}
m.AddVideo(uploaded)
} else if msg.Animation != nil {
name := "animation.mp4"
if msg.Animation.FileName != "" {
name = msg.Animation.FileName
}
uploaded, err := b.uploadTgMediaToMax(ctx, msg.Animation.FileID, maxschemes.VIDEO, name)
if err != nil {
slog.Error("TG→MAX edit gif upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось обновить GIF \"%s\" в MAX", name), err), nil)
return
}
m.AddVideo(uploaded)
} else if msg.Document != nil {
name := msg.Document.FileName
uploadType := maxschemes.FILE
isVideo := strings.HasPrefix(msg.Document.MimeType, "video/")
if isVideo {
uploadType = maxschemes.VIDEO
if name == "" {
name = mimeToFilename("video", msg.Document.MimeType)
}
}
if name == "" {
name = mimeToFilename("document", msg.Document.MimeType)
}
uploaded, err := b.uploadTgMediaToMax(ctx, msg.Document.FileID, uploadType, name)
if err != nil {
slog.Error("TG→MAX edit document upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось обновить файл \"%s\" в MAX", name), err), nil)
return
}
if isVideo {
m.AddVideo(uploaded)
} else {
m.AddFile(uploaded)
}
} else if msg.Audio != nil {
name := "audio.mp3"
if msg.Audio.FileName != "" {
name = msg.Audio.FileName
}
uploaded, err := b.uploadTgMediaToMax(ctx, msg.Audio.FileID, maxschemes.FILE, name)
if err != nil {
slog.Error("TG→MAX edit audio upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось обновить аудио \"%s\" в MAX", name), err), nil)
return
}
m.AddFile(uploaded)
} else if msg.Voice != nil {
uploaded, err := b.uploadTgMediaToMax(ctx, msg.Voice.FileID, maxschemes.AUDIO, "voice.ogg")
if err != nil {
slog.Error("TG→MAX edit voice upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось обновить голосовое в MAX", err), nil)
return
}
m.AddAudio(uploaded)
}
// Стикеры/VideoNote редактирование подписи не поддерживают со стороны TG, поэтому пропускаем.
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
slog.Error("TG→MAX edit media failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxMsgID", maxMsgID)
} else {
slog.Info("TG→MAX edited media", "mid", maxMsgID, "uid", uid, "tgChat", msg.Chat.ID)
}
}
// handleTgChannelPost обрабатывает посты из TG-каналов (только пересылка crosspost).
func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *TGMessage) {
// Запоминаем канал (для мастера линковки в кабинете).
b.noteBotChat("tg", msg.Chat.ID, msg.Chat.Title, "channel")
// Команды в канале игнорируем — настройка через личку с ботом
text := strings.TrimSpace(msg.Text)
if strings.HasPrefix(text, "/") {
return
}
// Пересылка crosspost: TG → MAX
maxChatID, direction, ok := b.repo.GetCrosspostMaxChat(msg.Chat.ID)
if !ok {
return
}
if direction == "max>tg" {
return // только MAX→TG, пропускаем
}
// Аддон решает, доставлять ли пост связки (и сам уведомит владельца, если нет).
if !b.crosspostDeliverable(ctx, maxChatID) {
return
}
// Anti-loop
checkText := msg.Text
if checkText == "" {
checkText = msg.Caption
}
if strings.HasPrefix(checkText, "[MAX]") || strings.HasPrefix(checkText, "[TG]") {
return
}
// Идемпотентность: этот пост канала уже кросспостился (повторная доставка вебхука /
// дубль-строка связки / перекрытие) — атомарный claim по (канал, msg_id) в БД.
// Уже застолблён → не кросспостим повторно. Старые записи чистятся по TTL.
if !b.repo.ClaimCrosspost("tg", msg.Chat.ID, strconv.Itoa(msg.MessageID)) {
slog.Info("skip duplicate crosspost TG→MAX (already claimed)", "tgChannel", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID)
return
}
go b.publishTgCrosspost(ctx, msg, maxChatID)
}
// publishTgCrosspost формирует caption (markdown-разметка + замены TG→MAX) и
// публикует TG-пост в MAX как кросспост: альбом → буфер, иначе одиночное сообщение.
// Единая точка для обычного кросспостинга канала и импорта истории — логику не
// дублируем. Синхронная: канал оборачивает в goroutine, импорт зовёт напрямую и
// ждёт завершения (чтобы удалить scratch-копию только после публикации).
func (b *Bridge) publishTgCrosspost(ctx context.Context, msg *TGMessage, maxChatID int64) {
caption := formatTgCrosspostCaption(msg)
// Применяем замены для TG→MAX
repl := b.repo.GetCrosspostReplacements(maxChatID)
if len(repl.TgToMax) > 0 {
caption = applyReplacements(caption, repl.TgToMax)
}
// Media group (альбом) — буферизуем и отправляем вместе
if msg.MediaGroupID != "" {
videoID := ""
if msg.Video != nil {
videoID = msg.Video.FileID
}
b.bufferMediaGroup(ctx, msg.MediaGroupID, mediaGroupItem{
photoSizes: msg.Photo,
videoFileID: videoID,
caption: caption,
replyToMsg: msg.ReplyToMessage,
entities: msg.CaptionEntities,
msg: msg,
maxChatID: maxChatID,
crosspost: true,
})
return
}
b.forwardTgToMax(ctx, msg, maxChatID, caption, true, false)
}
// handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management).
func (b *Bridge) handleTgCallback(ctx context.Context, query *TGCallback) {
if query.Message == nil || query.From == nil {
return
}
data := query.Data
chatID := query.Message.Chat.ID
msgID := query.Message.MessageID
fromID := query.From.ID
// Опциональный аддон первым получает callback — если это его кнопка, обработает.
if b.addon != nil {
if b.addon.HandleCallback(ctx, fromID, chatID, query.ID, data, msgID) {
return
}
}
// Навигация по меню /start (help:*) — редактируем то же сообщение.
if b.handleHelpMenuCallback(ctx, query, data) {
return
}
// cpd:dir:maxChatID — change direction
if strings.HasPrefix(data, "cpd:") {
parts := strings.SplitN(data, ":", 3)
if len(parts) != 3 {
return
}
dir := parts[1]
maxChatID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return
}
if dir != "tg>max" && dir != "max>tg" && dir != "both" {
return
}
if !b.isCrosspostOwner(maxChatID, fromID) {
b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может изменять настройки.")
return
}
b.repo.SetCrosspostDirection(maxChatID, dir)
// Получаем title канала (из текста сообщения)
title := parseTgCrosspostTitle(query.Message.Text)
text := tgCrosspostStatusText(title, dir)
kb := tgCrosspostKeyboard(dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "Готово")
return
}
// cps:maxChatID — toggle sync edits
if strings.HasPrefix(data, "cps:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cps:"), 10, 64)
if err != nil {
return
}
if !b.isCrosspostOwner(maxChatID, fromID) {
b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может изменять настройки.")
return
}
cur := b.repo.GetCrosspostSyncEdits(maxChatID)
b.repo.SetCrosspostSyncEdits(maxChatID, !cur)
title := parseTgCrosspostTitle(query.Message.Text)
_, direction, _ := b.repo.GetCrosspostTgChat(maxChatID)
text := tgCrosspostStatusText(title, direction)
kb := tgCrosspostKeyboard(direction, maxChatID, !cur)
b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb})
if !cur {
b.tg.AnswerCallback(ctx, query.ID, "Синхронизация правок включена")
} else {
b.tg.AnswerCallback(ctx, query.ID, "Синхронизация правок выключена")
}
return
}
// cpu:maxChatID — unlink (show confirmation)
if strings.HasPrefix(data, "cpu:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpu:"), 10, 64)
if err != nil {
return
}
if !b.isCrosspostOwner(maxChatID, fromID) {
b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.")
return
}
kb := NewInlineKeyboard(
NewInlineRow(
NewInlineButton("Да, удалить", fmt.Sprintf("cpuc:%d", maxChatID)),
NewInlineButton("Отмена", fmt.Sprintf("cpux:%d", maxChatID)),
),
)
b.tg.EditMessageText(ctx, chatID, msgID, "Удалить кросспостинг?", &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
// cpr:maxChatID — show replacements
if strings.HasPrefix(data, "cpr:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpr:"), 10, 64)
if err != nil {
return
}
repl := b.repo.GetCrosspostReplacements(maxChatID)
id := strconv.FormatInt(maxChatID, 10)
// Удаляем сообщение со связкой
b.tg.DeleteMessage(ctx, chatID, msgID)
// Заголовок с кнопками добавления
kb := tgReplacementsKeyboard(maxChatID)
b.tg.SendMessage(ctx, chatID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb})
// Каждая замена — отдельное сообщение с кнопкой удаления
for i, r := range repl.TgToMax {
b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "tg>max"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("tg>max", i, id, r.Target)})
}
for i, r := range repl.MaxToTg {
b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "max>tg"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("max>tg", i, id, r.Target)})
}
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
// cprt:dir:index:target:maxChatID — toggle replacement target
if strings.HasPrefix(data, "cprt:") {
parts := strings.SplitN(strings.TrimPrefix(data, "cprt:"), ":", 4)
if len(parts) != 4 {
return
}
dir := parts[0]
idx, err := strconv.Atoi(parts[1])
if err != nil {
return
}
newTarget := parts[2]
maxChatID, err := strconv.ParseInt(parts[3], 10, 64)
if err != nil {
return
}
repl := b.repo.GetCrosspostReplacements(maxChatID)
id := strconv.FormatInt(maxChatID, 10)
var r *Replacement
if dir == "tg>max" && idx < len(repl.TgToMax) {
r = &repl.TgToMax[idx]
} else if dir == "max>tg" && idx < len(repl.MaxToTg) {
r = &repl.MaxToTg[idx]
}
if r == nil {
return
}
r.Target = newTarget
b.repo.SetCrosspostReplacements(maxChatID, repl)
// Обновляем сообщение
newText := formatReplacementItem(*r, dir)
kb := tgReplItemKeyboard(dir, idx, id, r.Target)
b.tg.EditMessageText(ctx, chatID, msgID, newText, &SendOpts{ParseMode: "HTML", ReplyMarkup: kb})
label := "весь текст"
if newTarget == "links" {
label = "только ссылки"
}
b.tg.AnswerCallback(ctx, query.ID, "Тип: "+label)
return
}
// cprd:dir:index:maxChatID — delete single replacement
if strings.HasPrefix(data, "cprd:") {
parts := strings.SplitN(strings.TrimPrefix(data, "cprd:"), ":", 3)
if len(parts) != 3 {
return
}
dir := parts[0]
idx, err := strconv.Atoi(parts[1])
if err != nil {
return
}
maxChatID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return
}
repl := b.repo.GetCrosspostReplacements(maxChatID)
if dir == "tg>max" && idx < len(repl.TgToMax) {
repl.TgToMax = append(repl.TgToMax[:idx], repl.TgToMax[idx+1:]...)
} else if dir == "max>tg" && idx < len(repl.MaxToTg) {
repl.MaxToTg = append(repl.MaxToTg[:idx], repl.MaxToTg[idx+1:]...)
}
b.repo.SetCrosspostReplacements(maxChatID, repl)
b.tg.EditMessageText(ctx, chatID, msgID, "Замена удалена.", nil)
b.tg.AnswerCallback(ctx, query.ID, "Удалено")
return
}
// cpra:dir:maxChatID — choose target (all or links)
if strings.HasPrefix(data, "cpra:") {
parts := strings.SplitN(strings.TrimPrefix(data, "cpra:"), ":", 2)
if len(parts) != 2 {
return
}
dir := parts[0]
id := parts[1]
dirLabel := "TG → MAX"
if dir == "max>tg" {
dirLabel = "MAX → TG"
}
kb := NewInlineKeyboard(
NewInlineRow(
NewInlineButton("📝 Весь текст", "cprat:"+dir+":all:"+id),
NewInlineButton("🔗 Только ссылки", "cprat:"+dir+":links:"+id),
),
)
b.tg.EditMessageText(ctx, chatID, msgID,
fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
// cprat:dir:target:maxChatID — set wait state with target
if strings.HasPrefix(data, "cprat:") {
parts := strings.SplitN(strings.TrimPrefix(data, "cprat:"), ":", 3)
if len(parts) != 3 {
return
}
dir := parts[0]
target := parts[1]
maxChatID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return
}
b.setReplWait(fromID, maxChatID, dir, target)
b.tg.EditMessageText(ctx, chatID, msgID,
"✏️ Какой текст на какой заменить?\n\n"+
"Напишите одной строкой и разделите вертикальной чертой «|»:\n"+
"<b>что заменить | на что заменить</b>\n\n"+
"Примеры:\n"+
"• <code>наш Телеграм | наш канал в MAX</code>\n"+
" — заменит эту фразу во всех постах\n"+
"• <code>t.me/old_channel | max.ru/new_channel</code>\n"+
" — заменит ссылку\n"+
"• <code>#реклама | </code>\n"+
" — удалит текст (правую часть оставили пустой)\n\n"+
"Просто отправьте такую строку сообщением.\n\n"+
"Для продвинутых (регулярное выражение): <code>/utm_source=\\w+/ | utm_source=max</code>",
&SendOpts{ParseMode: "HTML"})
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
// cprc:maxChatID — clear all replacements
if strings.HasPrefix(data, "cprc:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprc:"), 10, 64)
if err != nil {
return
}
b.repo.SetCrosspostReplacements(maxChatID, CrosspostReplacements{})
repl := b.repo.GetCrosspostReplacements(maxChatID)
kb := tgReplacementsKeyboard(maxChatID)
b.tg.EditMessageText(ctx, chatID, msgID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "Очищено")
return
}
// cprb:maxChatID — back to crosspost management
if strings.HasPrefix(data, "cprb:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprb:"), 10, 64)
if err != nil {
return
}
_, direction, ok := b.repo.GetCrosspostTgChat(maxChatID)
if !ok {
return
}
title := parseTgCrosspostTitle(query.Message.Text)
text := tgCrosspostStatusText(title, direction) + fmt.Sprintf("\nTG: ↔ MAX: %d", maxChatID)
kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
// cpuc:maxChatID — unlink confirmed
if strings.HasPrefix(data, "cpuc:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpuc:"), 10, 64)
if err != nil {
return
}
if !b.isCrosspostOwner(maxChatID, fromID) {
b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.")
return
}
slog.Info("TG crosspost unlink", "maxChatID", maxChatID, "by", fromID)
b.repo.UnpairCrosspost(maxChatID, fromID)
b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг удалён.", nil)
b.tg.AnswerCallback(ctx, query.ID, "Удалено")
return
}
// cpux:maxChatID — cancel (return to management keyboard)
if strings.HasPrefix(data, "cpux:") {
maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpux:"), 10, 64)
if err != nil {
return
}
// Lookup current direction
_, direction, ok := b.repo.GetCrosspostTgChat(maxChatID)
if !ok {
b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг не найден.", nil)
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
title := parseTgCrosspostTitle(query.Message.Text)
text := tgCrosspostStatusText(title, direction)
kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb})
b.tg.AnswerCallback(ctx, query.ID, "")
return
}
}
// tgCrosspostKeyboard строит inline-клавиатуру для управления кросспостингом.
func tgCrosspostKeyboard(direction string, maxChatID int64, syncEdits bool) *InlineKeyboardMarkup {
lblTgMax := "TG → MAX"
lblMaxTg := "MAX → TG"
lblBoth := "⟷ Оба"
switch direction {
case "tg>max":
lblTgMax = "✓ TG → MAX"
case "max>tg":
lblMaxTg = "✓ MAX → TG"
default: // "both"
lblBoth = "✓ ⟷ Оба"
}
id := strconv.FormatInt(maxChatID, 10)
lblSync := "✏️ Синк правок"
if syncEdits {
lblSync = "✓ ✏️ Синк правок"
}
return NewInlineKeyboard(
NewInlineRow(
NewInlineButton(lblTgMax, "cpd:tg>max:"+id),
NewInlineButton(lblMaxTg, "cpd:max>tg:"+id),
NewInlineButton(lblBoth, "cpd:both:"+id),
),
NewInlineRow(
NewInlineButton(lblSync, "cps:"+id),
NewInlineButton("🔄 Замены", "cpr:"+id),
NewInlineButton("❌ Удалить", "cpu:"+id),
),
)
}
// tgCrosspostStatusText возвращает текст статуса кросспостинга.
func tgCrosspostStatusText(title, direction string) string {
dirLabel := "⟷ оба"
switch direction {
case "tg>max":
dirLabel = "TG → MAX"
case "max>tg":
dirLabel = "MAX → TG"
}
if title != "" {
return fmt.Sprintf("Кросспостинг «%s»\nНаправление: %s", title, dirLabel)
}
return fmt.Sprintf("Кросспостинг\nНаправление: %s", dirLabel)
}
// parseTgCrosspostTitle извлекает название канала из текста сообщения.
func parseTgCrosspostTitle(text string) string {
// Ищем «...» в тексте
start := strings.Index(text, "«")
end := strings.Index(text, "»")
if start >= 0 && end > start {
return text[start+len("«") : end]
}
return ""
}
// handleTgEditedChannelPost обрабатывает редактирования постов в TG-каналах.
func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *TGMessage) {
maxMsgID, ok := b.repo.LookupMaxMsgID(edited.Chat.ID, edited.MessageID)
if !ok {
return
}
maxChatID, direction, linked := b.repo.GetCrosspostMaxChat(edited.Chat.ID)
if !linked {
return
}
if direction == "max>tg" {
return
}
if !b.repo.GetCrosspostSyncEdits(maxChatID) {
return
}
text := edited.Text
if text == "" {
text = edited.Caption
}
if text == "" {
return
}
m := maxbot.NewMessage().SetChat(maxChatID).SetText(text)
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
slog.Error("TG→MAX crosspost edit failed", "err", err)
} else {
slog.Info("TG→MAX crosspost edited", "mid", maxMsgID)
}
}