1132 lines
42 KiB
Go
1132 lines
42 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"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) listenMax(ctx context.Context) {
|
||
var updates <-chan maxschemes.UpdateInterface
|
||
|
||
if b.cfg.WebhookURL != "" {
|
||
whPath := b.maxWebhookPath()
|
||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
|
||
ch := make(chan maxschemes.UpdateInterface, 100)
|
||
http.HandleFunc(whPath, b.maxApi.GetHandler(ch))
|
||
updateTypes := []string{
|
||
"message_created", "message_edited", "message_removed",
|
||
"message_callback", "bot_added", "bot_removed",
|
||
"user_added", "user_removed", "chat_title_changed",
|
||
}
|
||
if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, updateTypes, ""); err != nil {
|
||
slog.Error("MAX webhook subscribe failed", "err", err)
|
||
return
|
||
}
|
||
updates = ch
|
||
slog.Info("MAX webhook mode")
|
||
} else {
|
||
updates = b.maxApi.GetUpdates(ctx)
|
||
slog.Info("MAX polling mode")
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case upd, ok := <-updates:
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
slog.Debug("MAX update", "type", fmt.Sprintf("%T", upd))
|
||
|
||
// Обработка удаления (только bridge, не crosspost)
|
||
if delUpd, isDel := upd.(*maxschemes.MessageRemovedUpdate); isDel {
|
||
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(delUpd.MessageId)
|
||
if !ok {
|
||
continue
|
||
}
|
||
// Delete sync для crosspost: проверяем настройку sync_edits и direction
|
||
if maxCP, dir, cpOk := b.repo.GetCrosspostMaxChat(tgChatID); cpOk {
|
||
if !b.repo.GetCrosspostSyncEdits(maxCP) || dir == "tg>max" {
|
||
continue
|
||
}
|
||
}
|
||
if err := b.tg.DeleteMessage(ctx, tgChatID, tgMsgID); err != nil {
|
||
slog.Error("MAX→TG delete failed", "err", err, "maxMid", delUpd.MessageId, "tgChat", tgChatID)
|
||
} else {
|
||
slog.Info("MAX→TG deleted", "tgMsg", tgMsgID, "tgChat", tgChatID)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Обработка edit
|
||
if editUpd, isEdit := upd.(*maxschemes.MessageEditedUpdate); isEdit {
|
||
if editUpd.Message.Sender.IsBot {
|
||
continue
|
||
}
|
||
mid := editUpd.Message.Body.Mid
|
||
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(mid)
|
||
if !ok {
|
||
continue
|
||
}
|
||
// Edit sync для crosspost: проверяем настройку sync_edits и direction
|
||
if maxCP, dir, cpOk := b.repo.GetCrosspostMaxChat(tgChatID); cpOk {
|
||
if !b.repo.GetCrosspostSyncEdits(maxCP) || dir == "tg>max" {
|
||
continue
|
||
}
|
||
}
|
||
prefix := b.repo.HasPrefix("max", editUpd.Message.Recipient.ChatId)
|
||
name := editUpd.Message.Sender.Name
|
||
if name == "" {
|
||
name = editUpd.Message.Sender.Username
|
||
}
|
||
text := editUpd.Message.Body.Text
|
||
if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") {
|
||
continue
|
||
}
|
||
var fwd string
|
||
if prefix {
|
||
fwd = formatAttribution("[MAX] "+name, text, b.cfg.MessageNewline)
|
||
} else {
|
||
fwd = formatAttribution(name, text, b.cfg.MessageNewline)
|
||
}
|
||
|
||
// Проверяем вложения в edit — если есть медиа, используем editMessageMedia
|
||
var mediaURL, mediaType string
|
||
for _, att := range editUpd.Message.Body.Attachments {
|
||
switch a := att.(type) {
|
||
case *maxschemes.PhotoAttachment:
|
||
if a.Payload.Url != "" {
|
||
mediaURL, mediaType = a.Payload.Url, "photo"
|
||
}
|
||
case *maxschemes.VideoAttachment:
|
||
if a.Payload.Url != "" {
|
||
mediaURL, mediaType = a.Payload.Url, "video"
|
||
}
|
||
case *maxschemes.FileAttachment:
|
||
if a.Payload.Url != "" {
|
||
mediaURL, mediaType = a.Payload.Url, "document"
|
||
}
|
||
}
|
||
if mediaURL != "" {
|
||
break
|
||
}
|
||
}
|
||
|
||
if mediaURL != "" {
|
||
// Скачиваем медиа и отправляем editMessageMedia
|
||
data, name, dlErr := b.downloadURLWithLimit(mediaURL, b.cfg.maxMaxFileBytes())
|
||
if dlErr != nil {
|
||
slog.Error("MAX→TG edit media download failed", "err", dlErr)
|
||
} else {
|
||
var mediaIM TGInputMedia
|
||
switch mediaType {
|
||
case "photo":
|
||
mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||
case "video":
|
||
mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||
case "document":
|
||
mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||
}
|
||
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())
|
||
} else {
|
||
slog.Info("MAX→TG edited media", "tgMsg", tgMsgID, "type", mediaType, "uid", editUpd.Message.Sender.UserId)
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
|
||
if text == "" {
|
||
continue
|
||
}
|
||
if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, nil); 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)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Обработка inline-кнопок (crosspost management)
|
||
if cbUpd, isCb := upd.(*maxschemes.MessageCallbackUpdate); isCb {
|
||
b.handleMaxCallback(ctx, cbUpd)
|
||
continue
|
||
}
|
||
|
||
msgUpd, isMsg := upd.(*maxschemes.MessageCreatedUpdate)
|
||
if !isMsg {
|
||
continue
|
||
}
|
||
|
||
body := msgUpd.Message.Body
|
||
chatID := msgUpd.Message.Recipient.ChatId
|
||
text := strings.TrimSpace(body.Text)
|
||
isDialog := msgUpd.Message.Recipient.ChatType == "dialog"
|
||
|
||
slog.Debug("MAX msg received", "uid", msgUpd.Message.Sender.UserId, "chat", chatID, "type", msgUpd.Message.Recipient.ChatType)
|
||
|
||
// Запоминаем юзера при личном сообщении
|
||
if isDialog && msgUpd.Message.Sender.UserId != 0 {
|
||
b.repo.TouchUser(msgUpd.Message.Sender.UserId, "max", msgUpd.Message.Sender.Username, msgUpd.Message.Sender.Name)
|
||
}
|
||
|
||
if text == "/whoami" {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Бот-мост между MAX и Telegram.\n" +
|
||
"Версия: 0.3.2")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
if text == "/start" || text == "/help" {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Бот-мост между MAX и Telegram.\n\n" +
|
||
"Команды (группы):\n" +
|
||
"/bridge — создать ключ для связки чатов\n" +
|
||
"/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" +
|
||
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" +
|
||
"/unbridge — удалить связку\n\n" +
|
||
"Кросспостинг каналов (в личке бота):\n" +
|
||
"/crosspost <TG_ID> — связать MAX-канал с TG-каналом\n" +
|
||
" (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" +
|
||
"Как связать каналы:\n" +
|
||
"1. Добавьте бота админом в оба канала (с правом постинга)\n" +
|
||
" TG: " + b.cfg.TgBotURL + "\n" +
|
||
"2. Перешлите пост из TG-канала в личку TG-бота\n" +
|
||
"3. Бот покажет ID канала — скопируйте\n" +
|
||
"4. Здесь в личке напишите: /crosspost <TG_ID>\n" +
|
||
"5. Перешлите пост из MAX-канала сюда → готово!\n\n" +
|
||
"/crosspost — список всех связок с кнопками управления\n" +
|
||
"Управление: перешлите пост из связанного канала → кнопки\n\n" +
|
||
"Автозамены в кросспостинге:\n" +
|
||
"В настройках связки (кнопка 🔄) можно добавить замены текста.\n" +
|
||
"Формат: текст | замена или /regex/ | замена\n" +
|
||
"Можно заменять только в ссылках или во всём тексте.\n\n" +
|
||
"Как связать группы:\n" +
|
||
"1. Добавьте бота в оба чата\n" +
|
||
" MAX: " + b.cfg.MaxBotURL + "\n" +
|
||
"2. В одном из чатов отправьте /bridge\n" +
|
||
"3. Бот выдаст ключ — отправьте его в другом чате\n" +
|
||
"4. Готово!")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Проверка прав админа в группах
|
||
isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType)
|
||
isAdmin := false
|
||
if isGroup && msgUpd.Message.Sender.UserId != 0 {
|
||
admins, err := b.maxApi.Chats.GetChatAdmins(ctx, chatID)
|
||
if err == nil {
|
||
isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId)
|
||
}
|
||
} else if isGroup {
|
||
// В каналах MAX не передаёт sender userId — пропускаем проверку
|
||
isAdmin = true
|
||
}
|
||
|
||
// /bridge prefix on/off
|
||
if text == "/bridge prefix on" || text == "/bridge prefix off" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
on := text == "/bridge prefix on"
|
||
if b.repo.SetPrefix("max", chatID, on) {
|
||
reply := "Префикс [TG]/[MAX] включён."
|
||
if !on {
|
||
reply = "Префикс [TG]/[MAX] выключен."
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(reply)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Чат не связан. Сначала выполните /bridge.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// /bridge или /bridge <key>
|
||
if text == "/bridge" || strings.HasPrefix(text, "/bridge ") {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
|
||
paired, generatedKey, err := b.repo.Register(key, "max", chatID)
|
||
if err != nil {
|
||
slog.Error("register failed", "err", err)
|
||
continue
|
||
}
|
||
|
||
if paired {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связано! Сообщения теперь пересылаются.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
slog.Info("paired", "platform", "max", "chat", chatID, "key", key)
|
||
} else if generatedKey != "" {
|
||
m := maxbot.NewMessage().SetChat(chatID).
|
||
SetText(fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в Telegram-чате:\n/bridge %s\n\nTG-бот: %s", generatedKey, generatedKey, b.cfg.TgBotURL))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
slog.Info("pending", "platform", "max", "chat", chatID, "key", generatedKey)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или чат той же платформы.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if text == "/unbridge" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if b.repo.Unpair("max", chatID) {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связка удалена.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Обработка ввода замены (если юзер в режиме ожидания)
|
||
if isDialog && !strings.HasPrefix(text, "/") && msgUpd.Message.Sender.UserId != 0 {
|
||
if w, ok := b.getReplWait(msgUpd.Message.Sender.UserId); ok {
|
||
b.clearReplWait(msgUpd.Message.Sender.UserId)
|
||
rule, valid := parseReplacementInput(text)
|
||
if !valid {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Неверный формат. Используйте:\nfrom | to\nили\n/regex/ | to")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
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)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
ruleType := "строка"
|
||
if rule.Regex {
|
||
ruleType = "regex"
|
||
}
|
||
dirLabel := "TG → MAX"
|
||
if w.direction == "max>tg" {
|
||
dirLabel = "MAX → TG"
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("Замена добавлена (%s, %s):\n%s → %s", dirLabel, ruleType, rule.From, rule.To))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
}
|
||
|
||
// === Crosspost команды (только в личке бота) ===
|
||
|
||
// /crosspost <tg_channel_id> — начало настройки (только в личке)
|
||
if isDialog && strings.HasPrefix(text, "/crosspost") {
|
||
arg := strings.TrimSpace(strings.TrimPrefix(text, "/crosspost"))
|
||
if arg == "" {
|
||
links := b.repo.ListCrossposts(msgUpd.Message.Sender.UserId)
|
||
if len(links) == 0 {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Нет активных связок.\n\n" +
|
||
"Настройка:\n" +
|
||
"1. Перешлите пост из TG-канала в личку TG-бота\n" +
|
||
" " + b.cfg.TgBotURL + "\n" +
|
||
"2. Бот покажет ID канала\n" +
|
||
"3. Здесь напишите: /crosspost <TG_ID>\n" +
|
||
"4. Перешлите пост из MAX-канала сюда")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
} else {
|
||
for _, l := range links {
|
||
kb := maxCrosspostKeyboard(b.maxApi, l.Direction, l.MaxChatID, b.repo.GetCrosspostSyncEdits(l.MaxChatID))
|
||
tgTitle := b.tgChatTitle(ctx, l.TgChatID)
|
||
statusText := maxCrosspostStatusText(l.TgChatID, l.Direction)
|
||
if tgTitle != "" {
|
||
statusText = fmt.Sprintf("TG: «%s» (%d)\n", tgTitle, l.TgChatID) + statusText
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).
|
||
SetText(statusText).
|
||
AddKeyboard(kb)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
tgChannelID, err := strconv.ParseInt(arg, 10, 64)
|
||
if err != nil {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Неверный ID. Пример: /crosspost -1001234567890")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Сохраняем ожидание: userId → tgChannelID
|
||
b.cpWaitMu.Lock()
|
||
b.cpWait[msgUpd.Message.Sender.UserId] = tgChannelID
|
||
b.cpWaitMu.Unlock()
|
||
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("TG канал ID: %d\n\nТеперь перешлите любой пост из MAX-канала, который хотите связать.", tgChannelID))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
slog.Info("crosspost waiting for forward", "user", msgUpd.Message.Sender.UserId, "tgChannel", tgChannelID)
|
||
continue
|
||
}
|
||
|
||
// Пересланное сообщение в личке → завершение настройки crosspost или показ управления
|
||
if isDialog && msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.FORWARD {
|
||
maxChannelID := msgUpd.Message.Link.ChatId
|
||
|
||
userId := msgUpd.Message.Sender.UserId
|
||
b.cpWaitMu.Lock()
|
||
tgChannelID, waiting := b.cpWait[userId]
|
||
if waiting {
|
||
delete(b.cpWait, userId)
|
||
}
|
||
b.cpWaitMu.Unlock()
|
||
|
||
if waiting && maxChannelID != 0 {
|
||
// Проверяем, не связан ли уже
|
||
if _, _, ok := b.repo.GetCrosspostTgChat(maxChannelID); ok {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот MAX-канал уже связан.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Достаём TG owner ID (кто переслал пост из TG-канала в TG-бот)
|
||
b.cpTgOwnerMu.Lock()
|
||
tgOwnerID := b.cpTgOwner[tgChannelID]
|
||
b.cpTgOwnerMu.Unlock()
|
||
|
||
if err := b.repo.PairCrosspost(tgChannelID, maxChannelID, msgUpd.Message.Sender.UserId, tgOwnerID); err != nil {
|
||
slog.Error("crosspost pair failed", "err", err)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка при создании связки.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Показать статус + клавиатуру после паринга
|
||
kb := maxCrosspostKeyboard(b.maxApi, "both", maxChannelID, false)
|
||
m := maxbot.NewMessage().SetChat(chatID).
|
||
SetText(fmt.Sprintf("Кросспостинг настроен!\nTG: %d ↔ MAX: %d\nНаправление: ⟷ оба", tgChannelID, maxChannelID)).
|
||
AddKeyboard(kb)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
slog.Info("crosspost paired", "tg", tgChannelID, "max", maxChannelID, "maxOwner", msgUpd.Message.Sender.UserId, "tgOwner", tgOwnerID)
|
||
continue
|
||
}
|
||
|
||
// Нет cpWait — проверяем, связан ли канал → показать управление
|
||
if maxChannelID != 0 {
|
||
if tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChannelID); ok {
|
||
kb := maxCrosspostKeyboard(b.maxApi, direction, maxChannelID, b.repo.GetCrosspostSyncEdits(maxChannelID))
|
||
m := maxbot.NewMessage().SetChat(chatID).
|
||
SetText(maxCrosspostStatusText(tgID, direction)).
|
||
AddKeyboard(kb)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Канал не связан, cpWait нет — сообщить
|
||
if maxChannelID != 0 {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот канал не связан с кросспостингом.\n\nДля настройки:\n/crosspost <TG_ID>")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Пересылка (bridge)
|
||
tgChatID, linked := b.repo.GetTgChat(chatID)
|
||
if linked && !msgUpd.Message.Sender.IsBot {
|
||
// Anti-loop
|
||
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
|
||
prefix := b.repo.HasPrefix("max", chatID)
|
||
caption := formatMaxCaption(msgUpd, prefix, b.cfg.MessageNewline)
|
||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Пересылка (crosspost fallback)
|
||
if msgUpd.Message.Sender.IsBot {
|
||
continue
|
||
}
|
||
tgChatID, direction, cpLinked := b.repo.GetCrosspostTgChat(chatID)
|
||
if !cpLinked {
|
||
continue
|
||
}
|
||
if direction == "tg>max" {
|
||
continue // только TG→MAX, пропускаем
|
||
}
|
||
|
||
// Anti-loop
|
||
if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") {
|
||
continue
|
||
}
|
||
|
||
caption := formatMaxCrosspostCaption(msgUpd)
|
||
|
||
// Применяем замены для MAX→TG
|
||
repl := b.repo.GetCrosspostReplacements(chatID)
|
||
if len(repl.MaxToTg) > 0 {
|
||
caption = applyReplacements(caption, repl.MaxToTg)
|
||
}
|
||
|
||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleMaxCallback обрабатывает нажатия inline-кнопок (crosspost management).
|
||
func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.MessageCallbackUpdate) {
|
||
data := cbUpd.Callback.Payload
|
||
callbackID := cbUpd.Callback.CallbackID
|
||
userID := cbUpd.Callback.User.UserId
|
||
|
||
slog.Debug("MAX callback", "uid", userID, "data", data)
|
||
|
||
// 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, userID) {
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может изменять настройки.",
|
||
})
|
||
return
|
||
}
|
||
b.repo.SetCrosspostDirection(maxChatID, dir)
|
||
|
||
tgID, _, _ := b.repo.GetCrosspostTgChat(maxChatID)
|
||
body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, dir), dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: "Готово",
|
||
})
|
||
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, userID) {
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может изменять настройки.",
|
||
})
|
||
return
|
||
}
|
||
cur := b.repo.GetCrosspostSyncEdits(maxChatID)
|
||
b.repo.SetCrosspostSyncEdits(maxChatID, !cur)
|
||
tgID, direction, _ := b.repo.GetCrosspostTgChat(maxChatID)
|
||
body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, !cur)
|
||
note := "Синхронизация правок выключена"
|
||
if !cur {
|
||
note = "Синхронизация правок включена"
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: note,
|
||
})
|
||
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, userID) {
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может удалять.",
|
||
})
|
||
return
|
||
}
|
||
kb := b.maxApi.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().
|
||
AddCallback("Да, удалить", maxschemes.NEGATIVE, fmt.Sprintf("cpuc:%d", maxChatID)).
|
||
AddCallback("Отмена", maxschemes.DEFAULT, fmt.Sprintf("cpux:%d", maxChatID))
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: "Удалить кросспостинг?",
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
})
|
||
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, userID) {
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может удалять.",
|
||
})
|
||
return
|
||
}
|
||
slog.Info("MAX crosspost unlink", "maxChatID", maxChatID, "by", userID)
|
||
b.repo.UnpairCrosspost(maxChatID, userID)
|
||
body := &maxschemes.NewMessageBody{Text: "Кросспостинг удалён."}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: "Удалено",
|
||
})
|
||
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)
|
||
// Заголовок с кнопками добавления
|
||
kb := maxReplacementsKeyboard(b.maxApi, maxChatID)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: formatReplacementsHeader(repl),
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body})
|
||
// Каждая замена — отдельное сообщение с кнопками
|
||
for i, r := range repl.TgToMax {
|
||
dkb := maxReplItemKeyboard(b.maxApi, "tg>max", i, id, r.Target)
|
||
m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId).
|
||
SetText(formatReplacementItem(r, "tg>max")).
|
||
AddKeyboard(dkb)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
for i, r := range repl.MaxToTg {
|
||
dkb := maxReplItemKeyboard(b.maxApi, "max>tg", i, id, r.Target)
|
||
m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId).
|
||
SetText(formatReplacementItem(r, "max>tg")).
|
||
AddKeyboard(dkb)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
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)
|
||
dkb := maxReplItemKeyboard(b.maxApi, dir, idx, id, r.Target)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: newText,
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(dkb.Build())},
|
||
}
|
||
label := "весь текст"
|
||
if newTarget == "links" {
|
||
label = "только ссылки"
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: "Тип: " + 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)
|
||
body := &maxschemes.NewMessageBody{Text: "Замена удалена."}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: "Удалено",
|
||
})
|
||
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 := b.maxApi.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().
|
||
AddCallback("📝 Весь текст", maxschemes.DEFAULT, "cprat:"+dir+":all:"+id).
|
||
AddCallback("🔗 Только ссылки", maxschemes.DEFAULT, "cprat:"+dir+":links:"+id)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel),
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body})
|
||
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(userID, maxChatID, dir, target)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: "Отправьте правило замены:\nfrom | to\n\nДля регулярного выражения:\n/regex/ | to\n\nНапример:\nutm_source=tg | utm_source=max",
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body})
|
||
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 := maxReplacementsKeyboard(b.maxApi, maxChatID)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: formatReplacementsHeader(repl),
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
Notification: "Очищено",
|
||
})
|
||
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
|
||
}
|
||
tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChatID)
|
||
if !ok {
|
||
return
|
||
}
|
||
body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body})
|
||
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
|
||
}
|
||
tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChatID)
|
||
if !ok {
|
||
body := &maxschemes.NewMessageBody{Text: "Кросспостинг не найден."}
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
})
|
||
return
|
||
}
|
||
body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
// maxCrosspostMessageBody строит NewMessageBody с текстом и inline-клавиатурой.
|
||
func maxCrosspostMessageBody(api *maxbot.Api, text, direction string, maxChatID int64, syncEdits bool) *maxschemes.NewMessageBody {
|
||
kb := maxCrosspostKeyboard(api, direction, maxChatID, syncEdits)
|
||
return &maxschemes.NewMessageBody{
|
||
Text: text,
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
}
|
||
|
||
// maxCrosspostKeyboard строит inline-клавиатуру для управления кросспостингом в MAX.
|
||
func maxCrosspostKeyboard(api *maxbot.Api, direction string, maxChatID int64, syncEdits bool) *maxbot.Keyboard {
|
||
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 = "✓ ✏️ Синк правок"
|
||
}
|
||
kb := api.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().
|
||
AddCallback(lblTgMax, maxschemes.DEFAULT, "cpd:tg>max:"+id).
|
||
AddCallback(lblMaxTg, maxschemes.DEFAULT, "cpd:max>tg:"+id).
|
||
AddCallback(lblBoth, maxschemes.DEFAULT, "cpd:both:"+id)
|
||
kb.AddRow().
|
||
AddCallback(lblSync, maxschemes.DEFAULT, "cps:"+id).
|
||
AddCallback("🔄 Замены", maxschemes.DEFAULT, "cpr:"+id).
|
||
AddCallback("❌ Удалить", maxschemes.NEGATIVE, "cpu:"+id)
|
||
return kb
|
||
}
|
||
|
||
// maxCrosspostStatusText возвращает текст статуса кросспостинга для MAX.
|
||
func maxCrosspostStatusText(tgChatID int64, direction string) string {
|
||
dirLabel := "⟷ оба"
|
||
switch direction {
|
||
case "tg>max":
|
||
dirLabel = "TG → MAX"
|
||
case "max>tg":
|
||
dirLabel = "MAX → TG"
|
||
}
|
||
return fmt.Sprintf("Кросспостинг настроен\nTG: %d ↔ MAX\nНаправление: %s", tgChatID, dirLabel)
|
||
}
|
||
|
||
// forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат.
|
||
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string) {
|
||
if b.cbBlocked(tgChatID) {
|
||
return
|
||
}
|
||
|
||
threadID := b.repo.GetTgThreadID(tgChatID)
|
||
|
||
body := msgUpd.Message.Body
|
||
chatID := msgUpd.Message.Recipient.ChatId
|
||
text := strings.TrimSpace(body.Text)
|
||
|
||
// Reply ID
|
||
var replyToID int
|
||
if body.ReplyTo != "" {
|
||
if _, rid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
|
||
replyToID = rid
|
||
}
|
||
} else if msgUpd.Message.Link != nil {
|
||
mid := msgUpd.Message.Link.Message.Mid
|
||
if mid != "" {
|
||
if _, rid, ok := b.repo.LookupTgMsgID(mid); ok {
|
||
replyToID = rid
|
||
}
|
||
}
|
||
}
|
||
|
||
// Проверяем вложения
|
||
var sentMsgID int
|
||
var sendErr error
|
||
mediaSent := false
|
||
var qAttType, qAttURL string // для очереди при ошибке
|
||
|
||
// Определяем HTML caption если есть markups (для кросспостинга)
|
||
htmlCaption := caption
|
||
useHTML := len(body.Markups) > 0 && caption == text
|
||
if useHTML {
|
||
htmlCaption = maxMarkupsToHTML(text, body.Markups)
|
||
}
|
||
|
||
// Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia
|
||
var albumMedia []TGInputMedia
|
||
var soloMedia []struct {
|
||
url string
|
||
attType string
|
||
name string
|
||
}
|
||
pm := ""
|
||
if useHTML {
|
||
pm = "HTML"
|
||
}
|
||
|
||
for _, att := range body.Attachments {
|
||
switch a := att.(type) {
|
||
case *maxschemes.PhotoAttachment:
|
||
if a.Payload.Url != "" {
|
||
if len(albumMedia) == 0 {
|
||
qAttType, qAttURL = "photo", a.Payload.Url
|
||
}
|
||
p := TGInputMedia{Type: "photo", File: FileArg{URL: a.Payload.Url}}
|
||
albumMedia = append(albumMedia, p)
|
||
}
|
||
case *maxschemes.VideoAttachment:
|
||
if a.Payload.Url != "" {
|
||
if len(albumMedia) == 0 {
|
||
qAttType, qAttURL = "video", a.Payload.Url
|
||
}
|
||
v := TGInputMedia{Type: "video", File: FileArg{URL: a.Payload.Url}}
|
||
albumMedia = append(albumMedia, v)
|
||
}
|
||
case *maxschemes.AudioAttachment:
|
||
if a.Payload.Url != "" {
|
||
if qAttType == "" {
|
||
qAttType, qAttURL = "audio", a.Payload.Url
|
||
}
|
||
soloMedia = append(soloMedia, struct {
|
||
url string
|
||
attType string
|
||
name string
|
||
}{a.Payload.Url, "audio", ""})
|
||
}
|
||
case *maxschemes.FileAttachment:
|
||
if a.Payload.Url != "" {
|
||
if qAttType == "" {
|
||
qAttType, qAttURL = "file", a.Payload.Url
|
||
}
|
||
soloMedia = append(soloMedia, struct {
|
||
url string
|
||
attType string
|
||
name string
|
||
}{a.Payload.Url, "file", a.Filename})
|
||
}
|
||
case *maxschemes.StickerAttachment:
|
||
if a.Payload.Url != "" {
|
||
if qAttType == "" {
|
||
qAttType, qAttURL = "sticker", a.Payload.Url
|
||
}
|
||
soloMedia = append(soloMedia, struct {
|
||
url string
|
||
attType string
|
||
name string
|
||
}{a.Payload.Url, "sticker", ""})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Отправляем фото/видео как альбом (если их несколько — grouped, иначе — single)
|
||
if len(albumMedia) > 0 {
|
||
mediaSent = true
|
||
// Caption и reply только к первому элементу
|
||
if htmlCaption != "" || replyToID != 0 {
|
||
albumMedia[0].Caption = htmlCaption
|
||
if pm != "" {
|
||
albumMedia[0].ParseMode = pm
|
||
}
|
||
}
|
||
|
||
if len(albumMedia) == 1 {
|
||
// Одно вложение — отправляем обычным сообщением (альбом из 1 элемента не имеет reply)
|
||
sentMsgID, sendErr = b.sendTgMediaFromURL(ctx, tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, threadID, b.cfg.maxMaxFileBytes())
|
||
var e *ErrFileTooLarge
|
||
if errors.As(sendErr, &e) {
|
||
slog.Warn("MAX→TG media too big", "name", e.Name, "size", e.Size)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.",
|
||
e.Name, formatFileSize(int(e.Size)), b.cfg.MaxMaxFileSizeMB))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
} else {
|
||
// Несколько — отправляем как media group (альбом)
|
||
msgIDs, err := b.tg.SendMediaGroup(ctx, tgChatID, albumMedia, &SendOpts{ThreadID: threadID, ReplyToID: replyToID})
|
||
if err != nil {
|
||
slog.Error("MAX→TG album send failed", "err", err)
|
||
sendErr = err
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось отправить медиаальбом в Telegram.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
} else if len(msgIDs) > 0 {
|
||
sentMsgID = msgIDs[0]
|
||
}
|
||
}
|
||
}
|
||
|
||
// Отправляем остальные вложения (аудио, файлы, стикеры) по одному
|
||
// Если фото/видео не отправлялось, caption добавляем к первому вложению
|
||
firstSolo := true
|
||
for _, sm := range soloMedia {
|
||
smCaption := ""
|
||
smReplyTo := 0
|
||
if firstSolo && !mediaSent {
|
||
smCaption = htmlCaption
|
||
smReplyTo = replyToID
|
||
}
|
||
firstSolo = false
|
||
s, err := b.sendTgMediaFromURL(ctx, tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, threadID, b.cfg.maxMaxFileBytes(), sm.name)
|
||
if err != nil {
|
||
var e *ErrFileTooLarge
|
||
if errors.As(err, &e) {
|
||
slog.Warn("MAX→TG solo media too big", "name", e.Name, "size", e.Size)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.",
|
||
e.Name, formatFileSize(int(e.Size)), b.cfg.MaxMaxFileSizeMB))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
} else {
|
||
slog.Error("MAX→TG solo media send failed", "type", sm.attType, "err", err)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("Не удалось отправить файл \"%s\" в Telegram.", sm.name))
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
if sendErr == nil {
|
||
sendErr = err
|
||
}
|
||
} else if !mediaSent {
|
||
sentMsgID = s
|
||
mediaSent = true
|
||
}
|
||
}
|
||
|
||
// Текст без медиа
|
||
if !mediaSent {
|
||
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})
|
||
} else {
|
||
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID})
|
||
}
|
||
}
|
||
|
||
if sendErr != nil {
|
||
errStr := sendErr.Error()
|
||
slog.Error("MAX→TG send failed", "err", errStr, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||
|
||
// Группа преобразована в supergroup — автоматически мигрируем chat ID
|
||
var tgErr *TGError
|
||
if errors.As(sendErr, &tgErr) && tgErr.MigrateToChatID != 0 {
|
||
newChatID := tgErr.MigrateToChatID
|
||
slog.Info("TG chat migrated, updating pair", "old", tgChatID, "new", newChatID)
|
||
if err := b.repo.MigrateTgChat(tgChatID, newChatID); err != nil {
|
||
slog.Error("MigrateTgChat failed", "err", err)
|
||
} else {
|
||
// Повторяем отправку с новым ID
|
||
go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption)
|
||
}
|
||
return
|
||
}
|
||
if strings.Contains(errStr, "upgraded to a supergroup") {
|
||
// Fallback если не удалось получить новый ID из ошибки
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"TG-группа была преобразована в супергруппу. Перепривяжите чат: /unbridge в MAX, затем /bridge заново в обоих чатах.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
return
|
||
}
|
||
|
||
// TOPIC_CLOSED — General топик закрыт, уведомляем и не ретраим
|
||
if strings.Contains(errStr, "TOPIC_CLOSED") {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Не удалось переслать в Telegram: основной топик (General) закрыт.\nОткройте General в настройках TG-группы или сделайте бота админом.")
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
return
|
||
}
|
||
|
||
// Топики были выключены — сбрасываем thread_id и повторяем
|
||
if threadID != 0 && (strings.Contains(errStr, "message thread not found") ||
|
||
strings.Contains(errStr, "TOPIC_NOT_FOUND") ||
|
||
strings.Contains(errStr, "topics are disabled")) {
|
||
slog.Info("TG forum topics disabled, resetting thread_id", "tgChat", tgChatID, "oldThread", threadID)
|
||
b.repo.SetTgThreadID(tgChatID, 0)
|
||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||
return
|
||
}
|
||
|
||
parseMode := ""
|
||
if useHTML {
|
||
parseMode = "HTML"
|
||
}
|
||
var eTooLarge *ErrFileTooLarge
|
||
if !errors.As(sendErr, &eTooLarge) {
|
||
notifyText := "Не удалось переслать сообщение в Telegram. Попробуем ещё раз автоматически."
|
||
if b.cbBlocked(tgChatID) {
|
||
notifyText = "TG API недоступен. Сообщения в очереди, будут доставлены автоматически."
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(notifyText)
|
||
b.maxApi.Messages.Send(ctx, m)
|
||
}
|
||
b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, parseMode)
|
||
b.cbFail(tgChatID)
|
||
} else {
|
||
b.cbSuccess(tgChatID)
|
||
slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||
b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid)
|
||
}
|
||
}
|