Files
2026-05-27 16:11:13 +09:00

1132 lines
42 KiB
Go
Raw Permalink 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"
"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", generatedKey, generatedKey))
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)
}
}