mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-07-09 17:18:37 +00:00
Some checks failed
Build / build (push) Has been cancelled
- MAX→TG album: caption over Telegram's 1024 media-group limit is no longer lost — the album is sent without an inline caption and the full text follows as a separate message (sendMaxAlbumToTg, covers both direct and queued delivery). - Forwarded text: strip Telegram custom_emoji (MAX can't render them; the fallback glyph only littered the copy) in tgEntitiesToHTML, and collapse whitespace padding (runs of spaces/nbsp, trailing spaces, 3+ blank lines) in crosspost captions — cuts bloated length (MAX ~4000 char limit) and fixes ugly gaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u
1753 lines
75 KiB
Go
1753 lines
75 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||
)
|
||
|
||
// parseMaxRawAttachments превращает сырой JSON-массив вложений MAX в концретные
|
||
// типы (PhotoAttachment, VideoAttachment, FileAttachment, и т.д.). Используется
|
||
// для forwarded-сообщений: SDK не парсит Link.Message.RawAttachments если
|
||
// body.RawAttachments выставлен в [] (а не nil).
|
||
func parseMaxRawAttachments(raw []json.RawMessage) []interface{} {
|
||
out := make([]interface{}, 0, len(raw))
|
||
for _, r := range raw {
|
||
var base maxschemes.Attachment
|
||
if err := json.Unmarshal(r, &base); err != nil {
|
||
continue
|
||
}
|
||
var att interface{}
|
||
switch base.Type {
|
||
case maxschemes.AttachmentImage:
|
||
a := &maxschemes.PhotoAttachment{}
|
||
if err := json.Unmarshal(r, a); err == nil {
|
||
att = a
|
||
}
|
||
case maxschemes.AttachmentVideo:
|
||
a := &maxschemes.VideoAttachment{}
|
||
if err := json.Unmarshal(r, a); err == nil {
|
||
att = a
|
||
}
|
||
case maxschemes.AttachmentAudio:
|
||
a := &maxschemes.AudioAttachment{}
|
||
if err := json.Unmarshal(r, a); err == nil {
|
||
att = a
|
||
}
|
||
case maxschemes.AttachmentFile:
|
||
a := &maxschemes.FileAttachment{}
|
||
if err := json.Unmarshal(r, a); err == nil {
|
||
att = a
|
||
}
|
||
case maxschemes.AttachmentSticker:
|
||
a := &maxschemes.StickerAttachment{}
|
||
if err := json.Unmarshal(r, a); err == nil {
|
||
att = a
|
||
}
|
||
}
|
||
if att != nil {
|
||
out = append(out, att)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// openAppKeyboard собирает SDK-клавиатуру с одной OPEN_APP-кнопкой (для веток,
|
||
// где сообщение строится через maxbot.NewMessage()). nil — кнопки нет.
|
||
func (b *Bridge) openAppKeyboard(o *maxOpenApp) *maxbot.Keyboard {
|
||
if o == nil || o.AppName == "" {
|
||
return nil
|
||
}
|
||
kb := b.maxApi.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().AddOpenApp(o.Text, o.AppName, o.Payload, 0)
|
||
return kb
|
||
}
|
||
|
||
// noteBotChatMax запоминает MAX-чат (для мастера линковки), с title из getChat.
|
||
// Троттлится через тот же botChatSeen, что и TG — getChat зовётся не чаще раза в 10 мин.
|
||
func (b *Bridge) noteBotChatMax(ctx context.Context, chatID int64, chatType string) {
|
||
if chatID == 0 {
|
||
return
|
||
}
|
||
key := "max:" + strconv.FormatInt(chatID, 10)
|
||
now := time.Now().Unix()
|
||
if v, ok := botChatSeen.Load(key); ok {
|
||
if last, _ := v.(int64); now-last < 600 {
|
||
return
|
||
}
|
||
}
|
||
botChatSeen.Store(key, now)
|
||
title := ""
|
||
if ch, err := b.maxApi.Chats.GetChat(ctx, chatID); err == nil && ch != nil {
|
||
title = ch.Title
|
||
}
|
||
b.repo.RecordBotChat("max", chatID, title, chatType)
|
||
}
|
||
|
||
func (b *Bridge) listenMax(ctx context.Context) {
|
||
var updates <-chan maxschemes.UpdateInterface
|
||
var oldUpdates <-chan maxschemes.UpdateInterface // вебхук старого бота (дуал); nil иначе
|
||
|
||
if b.cfg.MaxWebhookURL != "" {
|
||
whPath := b.maxWebhookPath()
|
||
whURL := strings.TrimRight(b.cfg.MaxWebhookURL, "/") + 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",
|
||
"bot_started", // кнопка «Начать» в MAX — иначе бот молчит на старте
|
||
}
|
||
if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, updateTypes, ""); err != nil {
|
||
slog.Error("MAX webhook subscribe failed", "err", err)
|
||
return
|
||
}
|
||
// Второй (старый) бот — отдельный путь вебхука, ТОТ ЖЕ канал ch (единый диспетч).
|
||
// Дубли (оба бота в чате) отсекаются дедупом по mid в обработчике.
|
||
if b.dualEnabled() {
|
||
oldPath := whPath + "-old"
|
||
oldURL := strings.TrimRight(b.cfg.MaxWebhookURL, "/") + oldPath
|
||
// Отдельный канал для старого бота — чтобы в диспетчере знать, КТО доставил
|
||
// апдейт (нужно для ответов в ЛС/группах тем же ботом, что получил сообщение).
|
||
chOld := make(chan maxschemes.UpdateInterface, 100)
|
||
http.HandleFunc(oldPath, b.maxApiOld.GetHandler(chOld))
|
||
if _, err := b.maxApiOld.Subscriptions.Subscribe(ctx, oldURL, updateTypes, ""); err != nil {
|
||
slog.Error("MAX old-bot webhook subscribe failed", "err", err)
|
||
} else {
|
||
oldUpdates = chOld
|
||
slog.Info("MAX old-bot webhook subscribed", "path", oldPath)
|
||
}
|
||
}
|
||
|
||
updates = ch
|
||
slog.Info("MAX webhook mode")
|
||
|
||
// При завершении — снимаем подписку, чтобы MAX не долбился в мёртвый URL
|
||
// и чтобы при переключении в polling события сразу пошли (MAX иначе продолжит
|
||
// слать в старый webhook с экспоненциальным backoff).
|
||
defer func() {
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if _, err := b.maxApi.Subscriptions.Unsubscribe(shutdownCtx, whURL); err != nil {
|
||
slog.Error("MAX webhook unsubscribe failed", "err", err, "url", whURL)
|
||
} else {
|
||
slog.Info("MAX webhook unsubscribed", "url", whURL)
|
||
}
|
||
}()
|
||
} else {
|
||
updates = b.maxApi.GetUpdates(ctx)
|
||
slog.Info("MAX polling mode")
|
||
}
|
||
|
||
// Слияние апдейтов обоих ботов с пометкой бота-источника (чтобы отвечать тем же ботом).
|
||
type maxSrc struct {
|
||
upd maxschemes.UpdateInterface
|
||
tok string
|
||
}
|
||
merged := make(chan maxSrc, 200)
|
||
go func() {
|
||
for u := range updates {
|
||
merged <- maxSrc{u, b.cfg.MaxToken}
|
||
}
|
||
}()
|
||
if oldUpdates != nil {
|
||
go func() {
|
||
for u := range oldUpdates {
|
||
merged <- maxSrc{u, b.cfg.MaxTokenOld}
|
||
}
|
||
}()
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case m, ok := <-merged:
|
||
if !ok {
|
||
return
|
||
}
|
||
upd := m.upd
|
||
// Бот-источник апдейта → его токен в ctx: ответы (ЛС/группы/callback) уйдут
|
||
// тем же ботом (он точно в этом чате). maxClientFor читает токен из ctx.
|
||
ctx := b.withMaxToken(ctx, m.tok)
|
||
|
||
slog.Debug("MAX update", "type", fmt.Sprintf("%T", upd))
|
||
|
||
// Логируем выкидывание бота из чата — пара остаётся в БД (мб случайно
|
||
// выкинули, потом вернут), но видно кто/когда удалил.
|
||
if rm, isRm := upd.(*maxschemes.BotRemovedFromChatUpdate); isRm {
|
||
tgChatID, linked := b.repo.GetTgChat(rm.ChatId)
|
||
slog.Warn("MAX bot removed from chat",
|
||
"maxChat", rm.ChatId,
|
||
"byUser", rm.User.UserId,
|
||
"byUsername", rm.User.Username,
|
||
"byName", rm.User.Name,
|
||
"linkedTgChat", tgChatID,
|
||
"linked", linked)
|
||
continue
|
||
}
|
||
if add, isAdd := upd.(*maxschemes.BotAddedToChatUpdate); isAdd {
|
||
slog.Info("MAX bot added to chat",
|
||
"maxChat", add.ChatId,
|
||
"byUser", add.User.UserId,
|
||
"byUsername", add.User.Username,
|
||
"byName", add.User.Name)
|
||
continue
|
||
}
|
||
|
||
// Кнопка «Начать» в MAX (bot_started) — отвечаем приветствием, как на /start.
|
||
// Без этого новый бот молчит на старте (текст «/start» MAX не шлёт).
|
||
if bs, isStart := upd.(*maxschemes.BotStartedUpdate); isStart {
|
||
slog.Info("MAX bot started", "maxChat", bs.GetChatID(), "user", bs.GetUserID())
|
||
b.sendMaxStart(ctx, bs.GetChatID())
|
||
continue
|
||
}
|
||
|
||
// Обработка удаления (только 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 b.isSelfMaxBot(editUpd.Message.Sender.UserId) {
|
||
continue
|
||
}
|
||
// Дуал-бот: правка (message_edited) приходит от ОБОИХ ботов и НЕ дедупится
|
||
// как message_created. Без этого оба бота обрабатывают edit, и при фолбэке
|
||
// edit-media (re-send) пост ДУБЛИРУЕТСЯ дважды в TG. Дедуп по mid+содержимому:
|
||
// повтор той же правки режется, реальная следующая правка (другой текст/медиа) — нет.
|
||
if b.maxDupMid("edit:" + editUpd.Message.Body.Mid + ":" + editUpd.Message.Body.Text +
|
||
":" + strconv.Itoa(len(editUpd.Message.Body.Attachments))) {
|
||
continue
|
||
}
|
||
// Модерация правок: спамер может опубликовать чистый текст (пройдёт фильтр),
|
||
// затем отредактировать его в спам. Прогоняем edit через антиспам ДО
|
||
// зеркалирования и независимо от наличия маппинга (иначе ниже `if !ok continue`
|
||
// пропустил бы правку в standalone-группе).
|
||
if isMaxGroup(editUpd.Message.Recipient.ChatType) && editUpd.Message.Sender.UserId != 0 {
|
||
eChat := editUpd.Message.Recipient.ChatId
|
||
eText := editUpd.Message.Body.Text
|
||
isAdmin := false
|
||
if admins, err := b.maxApi.Chats.GetChatAdmins(ctx, eChat); err == nil {
|
||
isAdmin = isMaxUserAdmin(admins.Members, editUpd.Message.Sender.UserId)
|
||
}
|
||
hasLink := b.maxTextHasLink(eText)
|
||
if b.moderateGroupMessage(ctx, GroupMessage{
|
||
Platform: "max", ChatID: eChat, UserID: editUpd.Message.Sender.UserId,
|
||
MaxMid: editUpd.Message.Body.Mid, Text: eText, HasLink: hasLink, IsAdmin: isAdmin,
|
||
}) {
|
||
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.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
|
||
}
|
||
|
||
// Конвертируем в HTML (всегда, для жирного имени автора)
|
||
var fwd string
|
||
var editParseMode string
|
||
{
|
||
var htmlText string
|
||
if len(editUpd.Message.Body.Markups) > 0 {
|
||
htmlText = maxMarkupsToHTML(text, editUpd.Message.Body.Markups)
|
||
} else {
|
||
htmlText = html.EscapeString(text)
|
||
}
|
||
escapedName := html.EscapeString(name)
|
||
if prefix {
|
||
escapedName = "[MAX] " + escapedName
|
||
}
|
||
if b.cfg.MessageNewline {
|
||
fwd = "<b>" + escapedName + "</b>:\n" + htmlText
|
||
} else {
|
||
fwd = "<b>" + escapedName + "</b>: " + htmlText
|
||
}
|
||
editParseMode = "HTML"
|
||
}
|
||
|
||
// Проверяем вложения в 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, ParseMode: editParseMode}
|
||
case "video":
|
||
mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd, ParseMode: editParseMode}
|
||
case "document":
|
||
mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd, ParseMode: editParseMode}
|
||
}
|
||
if err := b.tg.EditMessageMedia(ctx, tgChatID, tgMsgID, mediaIM); err != nil {
|
||
slog.Error("MAX→TG edit media failed", "err", err, "uid", editUpd.Message.Sender.UserId)
|
||
// Fallback — отправляем как новое сообщение
|
||
go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, editParseMode, 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
|
||
}
|
||
var editOpts *SendOpts
|
||
if editParseMode != "" {
|
||
editOpts = &SendOpts{ParseMode: editParseMode}
|
||
}
|
||
if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, editOpts); err != nil {
|
||
slog.Error("MAX→TG edit failed", "err", err, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
|
||
} else {
|
||
slog.Info("MAX→TG edited", "tgMsg", tgMsgID, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Обработка inline-кнопок (crosspost management)
|
||
if cbUpd, isCb := upd.(*maxschemes.MessageCallbackUpdate); isCb {
|
||
b.handleMaxCallback(ctx, cbUpd)
|
||
continue
|
||
}
|
||
|
||
msgUpd, isMsg := upd.(*maxschemes.MessageCreatedUpdate)
|
||
if !isMsg {
|
||
continue
|
||
}
|
||
// Дуал-бот: одно и то же сообщение приходит от ОБОИХ ботов (если оба в чате) —
|
||
// обрабатываем один раз (mid уникален per-сообщение). Заодно метим бот чата.
|
||
if b.maxDupMid(msgUpd.Message.Body.Mid) {
|
||
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,
|
||
"textLen", len(body.Text), "att", len(body.Attachments), "rawAtt", len(body.RawAttachments), "markups", len(body.Markups))
|
||
|
||
// Запоминаем юзера при личном сообщении
|
||
if isDialog && msgUpd.Message.Sender.UserId != 0 {
|
||
b.repo.TouchUser(msgUpd.Message.Sender.UserId, "max", msgUpd.Message.Sender.Username, msgUpd.Message.Sender.Name)
|
||
}
|
||
// Запоминаем MAX-чат/канал (для мастера линковки в кабинете).
|
||
if !isDialog {
|
||
b.noteBotChatMax(ctx, chatID, string(msgUpd.Message.Recipient.ChatType))
|
||
}
|
||
|
||
// Команды MAX-диалога, обрабатываемые аддоном. Ядро не знает семантику.
|
||
if isDialog && strings.HasPrefix(text, "/") && b.maxAddonCommand(ctx, msgUpd.Message.Sender.UserId, chatID, text) {
|
||
continue
|
||
}
|
||
|
||
if text == "/whoami" {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"MaxTelegramBridgeBot — мост между Telegram и MAX.\n" +
|
||
"Автор: Andrey Lugovskoy (@BEARlogin)\n" +
|
||
"Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n" +
|
||
"Лицензия: CC BY-NC 4.0")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
if text == "/start" || text == "/help" {
|
||
b.sendMaxStart(ctx, chatID)
|
||
continue
|
||
}
|
||
|
||
// Проверка прав админа в группах
|
||
isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType)
|
||
isAdmin := false
|
||
if isGroup && msgUpd.Message.Sender.UserId != 0 {
|
||
// Через бота, который в чате (dual-bot): иначе новый бот в old-only чате
|
||
// не прочитает админов → владелец/админ ошибочно считается не-админом.
|
||
admins, err := b.maxClientFor(ctx, chatID).Chats.GetChatAdmins(ctx, chatID)
|
||
if err == nil {
|
||
isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId)
|
||
}
|
||
} else if isGroup {
|
||
// В каналах MAX не передаёт sender userId — пропускаем проверку
|
||
isAdmin = true
|
||
}
|
||
|
||
// Антиспам-модерация (аддон) для MAX-групп. Аддон сам решает по конфигу;
|
||
// если удалил как спам — дальше не зеркалим.
|
||
if isGroup && msgUpd.Message.Sender.UserId != 0 {
|
||
hasLink := b.maxTextHasLink(text)
|
||
if b.moderateGroupMessage(ctx, GroupMessage{
|
||
Platform: "max",
|
||
ChatID: chatID,
|
||
UserID: msgUpd.Message.Sender.UserId,
|
||
MaxMid: body.Mid,
|
||
Text: text,
|
||
HasLink: hasLink,
|
||
IsAdmin: isAdmin,
|
||
}) {
|
||
continue
|
||
}
|
||
}
|
||
|
||
// /bridge prefix on/off
|
||
if text == "/bridge prefix on" || text == "/bridge prefix off" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Чат не связан. Сначала выполните /bridge.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// /bridge_update — записать себя владельцем уже связанной группы (MAX-сторона).
|
||
if text == "/bridge_update" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if _, linked := b.repo.GetTgChat(chatID); !linked {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта группа не связана с Telegram. Сначала свяжите её через /bridge.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
var reply string
|
||
if b.repo.SetPairOwner("max", chatID, msgUpd.Message.Sender.UserId) {
|
||
reply = "Готово ✅ Связка обновлена."
|
||
slog.Info("bridge_update owner set", "platform", "max", "chat", chatID, "user", msgUpd.Message.Sender.UserId)
|
||
} else {
|
||
reply = "Не удалось обновить связку."
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(reply)
|
||
b.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
|
||
|
||
// /bridge без ключа — если чат уже связан, не создавать новый ключ
|
||
if key == "" {
|
||
if tgID, linked := b.repo.GetTgChat(chatID); linked {
|
||
var reply string
|
||
if isGroup {
|
||
reply = fmt.Sprintf("Эта группа уже связана с Telegram (ID %d).\n\n/unbridge — удалить связку.", tgID)
|
||
} else {
|
||
reply = fmt.Sprintf("Этот личный чат уже связан с Telegram (ID %d).\n\nЧтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\n/unbridge — удалить связку этого личного чата.", tgID)
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(reply)
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if !isGroup {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Чтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\nЕсли хотите связать этот личный чат с Telegram-пользователем — введите ключ от него: /bridge <ключ>.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
}
|
||
paired, generatedKey, err := b.repo.Register(key, "max", chatID, msgUpd.Message.Sender.UserId)
|
||
if err != nil {
|
||
slog.Error("register failed", "err", err)
|
||
continue
|
||
}
|
||
|
||
if paired {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связано! Сообщения теперь пересылаются.")
|
||
b.maxClientFor(ctx, chatID).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Добавьте TG-бота в нужную Telegram-группу и отправьте в ней (не в ЛС бота):\n/bridge %s\n\nСсылка на TG-бота (для добавления в группу): %s", generatedKey, generatedKey, b.cfg.TgBotURL))
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
slog.Info("pending", "platform", "max", "chat", chatID, "key", generatedKey)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или чат той же платформы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if text == "/unbridge" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if b.repo.Unpair("max", chatID) {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связка удалена.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Пауза/возобновление связки групп (не удаляя её).
|
||
if text == "/pause" || text == "/unpause" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
tgChatID, linked := b.repo.GetTgChat(chatID)
|
||
if !linked {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан (пауза — для связки групп).")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
pause := text == "/pause"
|
||
if err := b.repo.SetPairPaused(tgChatID, chatID, pause); err != nil {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось изменить паузу.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
txt := "▶️ Связка возобновлена — пересылка снова работает."
|
||
if pause {
|
||
txt = "⏸ Связка на паузе — сообщения не зеркалятся (в обе стороны). Возобновить: /unpause"
|
||
}
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(txt))
|
||
continue
|
||
}
|
||
|
||
// /thread_bridge <key> — принять ключ, связать MAX-чат с конкретным TG-тредом
|
||
if strings.HasPrefix(text, "/thread_bridge") {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(strings.TrimPrefix(text, "/thread_bridge"))
|
||
if key == "" {
|
||
if tgID, tid, ok := b.repo.GetThreadTgPair(chatID); ok {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("Этот чат уже связан с TG-тредом (чат %d, thread %d).\n\n/thread_unbridge — разорвать.", tgID, tid))
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Нужен ключ: /thread_bridge <ключ>\n\nСначала в Telegram-форум-группе выполните /thread_bridge внутри нужного треда — там выдадут ключ.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
tgChatID, threadID, ok, err := b.repo.CompleteThreadBridge(key, chatID)
|
||
if err == errThreadMaxBusy {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"Этот MAX-чат уже участвует в другой связке (bridge или thread-bridge). Сначала /unbridge или /thread_unbridge.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if err != nil {
|
||
slog.Error("CompleteThreadBridge failed", "err", err)
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения связки.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if !ok {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или истёк.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
fmt.Sprintf("Связано с TG-тредом (чат %d, thread %d). Сообщения из этого MAX-чата будут уходить в указанный тред, и обратно.", tgChatID, threadID))
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
slog.Info("thread-bridge paired", "tgChat", tgChatID, "thread", threadID, "maxChat", chatID)
|
||
continue
|
||
}
|
||
|
||
if text == "/thread_unbridge" {
|
||
if isGroup && !isAdmin {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
if b.repo.UnpairThreadByMax(chatID) {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связка треда удалена.")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
} else {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан с тредом.")
|
||
b.maxClientFor(ctx, chatID).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("Не получилось разобрать. Нужна одна строка с вертикальной чертой «|»:\nчто заменить | на что\n\nНапример: наш Телеграм | наш канал в MAX\n\nПопробуйте ещё раз через кнопку «🔄 Замены».")
|
||
b.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Валидация: ID TG-канала ВСЕГДА отрицательный (-100…). Положительный =
|
||
// уронили минус или «сырой» ID из ссылки t.me/c/… — связка молча не заработает.
|
||
if tgChannelID >= 0 {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||
"⚠️ Похоже, ID введён неверно.\n\nID TG-канала — ОТРИЦАТЕЛЬНЫЙ, начинается с -100, например: -1001234567890.\n\nЧто делать:\n1. Перешлите пост из TG-канала в личку TG-бота " + b.cfg.TgBotURL + "\n2. Он покажет ID — скопируйте ЦЕЛИКОМ, со знаком «-»\n3. Пришлите сюда: /crosspost <ID>")
|
||
b.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
// Достаём TG owner ID (кто переслал пост из TG-канала в TG-бот)
|
||
b.cpTgOwnerMu.Lock()
|
||
tgOwnerID := b.cpTgOwner[tgChannelID]
|
||
b.cpTgOwnerMu.Unlock()
|
||
|
||
// Новую связку создаём, только если аддон разрешил (иначе показываем его reason).
|
||
if ok, reason := b.crosspostAllowed(ctx, msgUpd.Message.Sender.UserId, tgOwnerID); !ok {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText(reason)
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
|
||
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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).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.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Канал не связан, cpWait нет — сообщить
|
||
if maxChannelID != 0 {
|
||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот канал не связан с кросспостингом.\n\nДля настройки:\n/crosspost <TG_ID>")
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, m)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Пересылка (bridge). Сначала проверяем thread-bridge (MAX-чат = отдельный TG-тред),
|
||
// потом обычную пару.
|
||
tgChatID, linked := b.repo.GetTgChat(chatID)
|
||
if !linked {
|
||
if tg, _, ok := b.repo.GetThreadTgPair(chatID); ok {
|
||
tgChatID = tg
|
||
linked = true
|
||
}
|
||
}
|
||
if linked && !b.isSelfMaxBot(msgUpd.Message.Sender.UserId) {
|
||
// Anti-loop
|
||
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
|
||
prefix := b.hasPrefix("max", chatID)
|
||
caption := formatMaxCaption(msgUpd, prefix, b.cfg.MessageNewline)
|
||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, false)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Пересылка (crosspost fallback)
|
||
if b.isSelfMaxBot(msgUpd.Message.Sender.UserId) {
|
||
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)
|
||
}
|
||
|
||
// Идемпотентность: этот пост MAX-канала уже кросспостился (повторная доставка /
|
||
// дубль-строка связки / перекрытие) — атомарный claim по (MAX-канал, mid) в БД.
|
||
if !b.repo.ClaimCrosspost("max", chatID, body.Mid) {
|
||
slog.Info("skip duplicate crosspost MAX→TG (already claimed)", "maxChannel", chatID, "mid", body.Mid, "tgChat", tgChatID)
|
||
continue
|
||
}
|
||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleMaxCallback обрабатывает нажатия inline-кнопок (crosspost management).
|
||
// sendMaxStart шлёт приветствие в MAX-чат (на /start и на кнопку «Начать»/bot_started).
|
||
func (b *Bridge) sendMaxStart(ctx context.Context, chatID int64) {
|
||
if ch := customHelp(); ch != "" {
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(helpPlain(ch)))
|
||
return
|
||
}
|
||
text := "Бот-мост между MAX и Telegram.\n\n" +
|
||
"Команды (группы):\n" +
|
||
"/bridge — создать ключ для связки чатов\n" +
|
||
"/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" +
|
||
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" +
|
||
"/unbridge — удалить связку\n" +
|
||
"/thread_bridge <ключ> — связать этот MAX-чат с отдельным TG-тредом (форум)\n" +
|
||
"/thread_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" +
|
||
" TG: " + b.cfg.TgBotURL + "\n" +
|
||
"2. В MAX сделайте бота админом группы С ПРАВОМ «Доступ к сообщениям» (читать все\n" +
|
||
" сообщения) — без него бот не видит команды и сообщения, связка не сработает\n" +
|
||
"3. В TG сделайте бота админом, если группа — супергруппа с темами (форум). " +
|
||
"В обычных группах админство не требуется.\n" +
|
||
"4. В одном из чатов отправьте /bridge\n" +
|
||
"5. Бот выдаст ключ — отправьте его в другом чате (в группе, не в ЛС бота)\n" +
|
||
"6. Готово!" + b.reserveBotHint() + "\n\n" +
|
||
"💬 Поддержка и новости: https://t.me/+0ucbOj4wBwQzMWNi"
|
||
b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(text))
|
||
}
|
||
|
||
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.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может изменять настройки.",
|
||
})
|
||
return
|
||
}
|
||
b.repo.SetCrosspostDirection(maxChatID, dir)
|
||
|
||
tgID, _, _ := b.repo.GetCrosspostTgChat(maxChatID)
|
||
body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, dir), dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, !cur)
|
||
note := "Синхронизация правок выключена"
|
||
if !cur {
|
||
note = "Синхронизация правок включена"
|
||
}
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Notification: "Только владелец связки может удалять.",
|
||
})
|
||
return
|
||
}
|
||
kb := b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0), maxChatID)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: formatReplacementsHeader(repl),
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body})
|
||
// Каждая замена — отдельное сообщение с кнопками
|
||
for i, r := range repl.TgToMax {
|
||
dkb := maxReplItemKeyboard(b.maxClientFor(ctx, 0), "tg>max", i, id, r.Target)
|
||
m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId).
|
||
SetText(formatReplacementItem(r, "tg>max")).
|
||
AddKeyboard(dkb)
|
||
b.maxClientFor(ctx, 0).Messages.Send(ctx, m)
|
||
}
|
||
for i, r := range repl.MaxToTg {
|
||
dkb := maxReplItemKeyboard(b.maxClientFor(ctx, 0), "max>tg", i, id, r.Target)
|
||
m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId).
|
||
SetText(formatReplacementItem(r, "max>tg")).
|
||
AddKeyboard(dkb)
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0), dir, idx, id, r.Target)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: newText,
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(dkb.Build())},
|
||
}
|
||
label := "весь текст"
|
||
if newTarget == "links" {
|
||
label = "только ссылки"
|
||
}
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).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: "✏️ Какой текст на какой заменить?\n\n" +
|
||
"Напишите одной строкой и разделите вертикальной чертой «|»:\n" +
|
||
"что заменить | на что заменить\n\n" +
|
||
"Примеры:\n" +
|
||
"• наш Телеграм | наш канал в MAX — заменит фразу во всех постах\n" +
|
||
"• t.me/old_channel | max.ru/new_channel — заменит ссылку\n" +
|
||
"• #реклама | — удалит текст (правую часть оставили пустой)\n\n" +
|
||
"Просто отправьте такую строку сообщением.\n\n" +
|
||
"Для продвинутых (регулярное выражение): /utm_source=\\w+/ | utm_source=max",
|
||
}
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0), maxChatID)
|
||
body := &maxschemes.NewMessageBody{
|
||
Text: formatReplacementsHeader(repl),
|
||
Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())},
|
||
}
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxClientFor(ctx, 0).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.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
})
|
||
return
|
||
}
|
||
body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID))
|
||
b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{
|
||
Message: body,
|
||
})
|
||
return
|
||
}
|
||
|
||
// Неизвестный ядру префикс — отдаём аддону (пикер антиспама asmax: и т.п.).
|
||
if b.addon != nil {
|
||
b.addon.HandleCallback(ctx, userID, userID, callbackID, data, 0)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// maxAlbumItem — элемент MAX-альбома для пересылки в TG (тип + URL на MAX-CDN).
|
||
// Сериализуется в JSON и кладётся в очередь (AttType="album"), чтобы альбом не терял фото.
|
||
type maxAlbumItem struct {
|
||
Type string `json:"t"` // "photo" | "video"
|
||
URL string `json:"u"`
|
||
}
|
||
|
||
// sendMaxAlbumToTg качает байты каждого элемента альбома и шлёт media group в TG.
|
||
// Байты (а не URL) — Telegram не фетчит MAX-CDN надёжно; уникальные attach-имена ставит
|
||
// SendMediaGroup (иначе все фото схлопывались в первое). caption/parseMode — на [0].
|
||
// Единый путь и для прямой пересылки, и для доставки из очереди.
|
||
func (b *Bridge) sendMaxAlbumToTg(ctx context.Context, tgChatID int64, items []maxAlbumItem, caption, parseMode string, threadID, replyToID int) ([]int, error) {
|
||
// Лимит подписи media-group в Telegram — 1024 символа. Длинную подпись (частый кейс —
|
||
// пост-инструкция с альбомом) НЕ вешаем на альбом (иначе «caption is too long» → фото
|
||
// уходят без текста), а шлём отдельным сообщением следом. Порог по рунам с запасом: для
|
||
// HTML-подписи теги завышают счёт — тогда просто отправим текст отдельно, это безопасно.
|
||
const tgMediaCaptionLimit = 1024
|
||
albumCaption := caption
|
||
overflowCaption := ""
|
||
if utf8.RuneCountInString(caption) > tgMediaCaptionLimit {
|
||
albumCaption = ""
|
||
overflowCaption = caption
|
||
}
|
||
|
||
media := make([]TGInputMedia, 0, len(items))
|
||
for i, it := range items {
|
||
typ := it.Type
|
||
if typ == "" {
|
||
typ = "photo"
|
||
}
|
||
im := TGInputMedia{Type: typ, File: FileArg{URL: it.URL}}
|
||
if data, name, dlErr := b.downloadURLWithLimit(it.URL, b.cfg.maxMaxFileBytes()); dlErr == nil {
|
||
im.File = FileArg{Name: name, Bytes: data}
|
||
} else {
|
||
slog.Warn("MAX→TG album item download failed, keep URL", "err", dlErr, "idx", i)
|
||
}
|
||
if i == 0 && albumCaption != "" {
|
||
im.Caption = albumCaption
|
||
im.ParseMode = parseMode
|
||
}
|
||
media = append(media, im)
|
||
}
|
||
msgs, err := b.tg.SendMediaGroup(ctx, tgChatID, media, &SendOpts{ThreadID: threadID, ReplyToID: replyToID})
|
||
if err != nil {
|
||
return msgs, err
|
||
}
|
||
// Длинная подпись — отдельным сообщением (после альбома, в том же треде).
|
||
if overflowCaption != "" {
|
||
if _, serr := b.tg.SendMessage(ctx, tgChatID, overflowCaption, &SendOpts{ParseMode: parseMode, ThreadID: threadID}); serr != nil {
|
||
slog.Warn("MAX→TG album long caption send failed", "tgChat", tgChatID, "err", serr)
|
||
}
|
||
}
|
||
return msgs, nil
|
||
}
|
||
|
||
// forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат.
|
||
// Если isCrosspost=true, caption используется как финальный текст (с заменами, без атрибуции).
|
||
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string, isCrosspost bool) {
|
||
if b.cbBlocked(tgChatID) {
|
||
return
|
||
}
|
||
|
||
body := msgUpd.Message.Body
|
||
chatID := msgUpd.Message.Recipient.ChatId
|
||
text := strings.TrimSpace(body.Text)
|
||
|
||
// Пауза связки — временно не пересылаем (связку не удаляем). Возобновить: /unpause.
|
||
if isCrosspost {
|
||
if b.repo.CrosspostPaused(chatID) {
|
||
return
|
||
}
|
||
} else if b.repo.PairPaused(tgChatID, chatID) {
|
||
return
|
||
}
|
||
|
||
// Дедуп: это MAX-сообщение уже переслано в TG (повторная доставка вебхука после
|
||
// рестарта / двойная доставка). Второй раз не шлём.
|
||
if body.Mid != "" {
|
||
if _, _, _, ok := b.repo.LookupTgMsgID(body.Mid); ok {
|
||
slog.Info("MAX→TG skip: already delivered (dedup)", "maxMid", body.Mid, "tgChat", tgChatID)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Forward (репост) из MAX: оригинал лежит в Link.Message, а body.Text часто пустой
|
||
// (если юзер не дописал свой комментарий). Подмешиваем содержимое Link.Message.Text,
|
||
// иначе в TG прилетит пустое "Name:" сообщение.
|
||
// Вложения оригинала тоже лежат в Link.Message.Attachments — body.Attachments при
|
||
// пересылке пустой, поэтому их нужно взять явно.
|
||
markups := body.Markups
|
||
if lk := msgUpd.Message.Link; lk != nil && lk.Type == maxschemes.FORWARD {
|
||
fwd := strings.TrimSpace(lk.Message.Text)
|
||
if fwd != "" {
|
||
if text != "" {
|
||
// У юзера есть комментарий — склеиваем коммент + перенос + переcланный текст.
|
||
// markups для склеенного текста невалидны, поэтому отбрасываем форматирование.
|
||
text = text + "\n\n" + fwd
|
||
markups = nil
|
||
} else {
|
||
// Только репост — берём текст и markups из Link.Message как есть.
|
||
text = fwd
|
||
markups = lk.Message.Markups
|
||
}
|
||
}
|
||
// Вложения оригинала добавляем перед вложениями обёртки (обычно она пустая).
|
||
// SDK MAX парсит Link.Message.RawAttachments → Attachments только если
|
||
// body.RawAttachments == nil. У forwarded-сообщений MAX шлёт body.RawAttachments=[]
|
||
// (пустой массив, не nil) → парсинг пропускается, Link.Message.Attachments пуст.
|
||
// Поэтому парсим сырые JSON-байты сами.
|
||
if len(lk.Message.Attachments) > 0 {
|
||
body.Attachments = append(lk.Message.Attachments, body.Attachments...)
|
||
} else if len(lk.Message.RawAttachments) > 0 {
|
||
parsed := parseMaxRawAttachments(lk.Message.RawAttachments)
|
||
if len(parsed) > 0 {
|
||
body.Attachments = append(parsed, body.Attachments...)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Relay-гейт ТОЛЬКО для бридж-ГРУПП (бот постит от своего имени → страйк боту).
|
||
// Каналы (даже через /bridge) и кросспосты не трогаем — бот там не отправитель.
|
||
if !isCrosspost && isMaxGroup(msgUpd.Message.Recipient.ChatType) {
|
||
// Скриним только тело (text), без caption — он содержит имя автора (вычурные ники
|
||
// травили mixed-script/emoji-junk и блокировали легит).
|
||
if block, reason := b.screenRelay(ctx, "max", chatID, msgUpd.Message.Sender.UserId, text); block {
|
||
slog.Warn("relay blocked MAX→TG (group): prohibited/spam",
|
||
"maxChat", chatID, "tgChat", tgChatID, "reason", reason)
|
||
// Soft-блок → карточка в MAX-чате (админ «Пропустить» → автор soft-approved).
|
||
// Жёсткие категории holdForModeration дропнет без карточки.
|
||
payload, _ := json.Marshal(msgUpd)
|
||
b.holdForModeration(ctx, "max", chatID, msgUpd.Message.Body.Mid, tgChatID,
|
||
msgUpd.Message.Sender.UserId, msgUpd.Message.Sender.Name, text, caption, reason, string(payload))
|
||
return
|
||
}
|
||
}
|
||
|
||
// Определяем тред-назначение:
|
||
// — thread-pair: MAX-чат жёстко привязан к одному TG-треду, все сообщения идут туда,
|
||
// reply-override не меняет тред.
|
||
// — обычная пара: дефолтный тред пары + для reply переопределяем на тред исходника
|
||
// (чтобы ответ попадал в ту же ветку, где лежит исходное сообщение).
|
||
var threadID int
|
||
_, threadPairThread, isThreadPair := b.repo.GetThreadTgPair(chatID)
|
||
if isThreadPair {
|
||
threadID = threadPairThread
|
||
} else {
|
||
threadID = b.repo.GetTgThreadID(tgChatID)
|
||
}
|
||
|
||
var replyToID int
|
||
if body.ReplyTo != "" {
|
||
if _, rid, tid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
|
||
replyToID = rid
|
||
if !isThreadPair {
|
||
threadID = tid
|
||
}
|
||
}
|
||
} else if msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.REPLY {
|
||
mid := msgUpd.Message.Link.Message.Mid
|
||
if mid != "" {
|
||
if _, rid, tid, ok := b.repo.LookupTgMsgID(mid); ok {
|
||
replyToID = rid
|
||
if !isThreadPair {
|
||
threadID = tid
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Проверяем вложения
|
||
var sentMsgID int
|
||
var sendErr error
|
||
mediaSent := false
|
||
var qAttType, qAttURL string // для очереди при ошибке
|
||
|
||
// Определяем HTML caption: всегда для bridge-режима (жирное имя) и при наличии markups
|
||
htmlCaption := caption
|
||
hasMarkups := len(markups) > 0
|
||
hasAttribution := !isCrosspost // bridge-режим (не кросспостинг)
|
||
useHTML := hasMarkups || hasAttribution
|
||
if useHTML {
|
||
var htmlText string
|
||
if hasMarkups {
|
||
htmlText = maxMarkupsToHTML(text, markups)
|
||
} else {
|
||
htmlText = html.EscapeString(text)
|
||
}
|
||
if !hasAttribution {
|
||
// Кросспостинг: caption = сырой текст, без атрибуции
|
||
htmlCaption = htmlText
|
||
} else {
|
||
// Bridge: caption с атрибуцией — жирное имя
|
||
name := maxName(msgUpd)
|
||
if b.hasPrefix("max", msgUpd.Message.Recipient.ChatId) {
|
||
name = "[MAX] " + name
|
||
}
|
||
escapedName := html.EscapeString(name)
|
||
if b.cfg.MessageNewline {
|
||
htmlCaption = "<b>" + escapedName + "</b>:\n" + htmlText
|
||
} else {
|
||
htmlCaption = "<b>" + escapedName + "</b>: " + htmlText
|
||
}
|
||
}
|
||
}
|
||
|
||
// Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia
|
||
var albumMedia []TGInputMedia
|
||
var albumItems []maxAlbumItem // тот же альбом как список {type,url} — для очереди (JSON)
|
||
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)
|
||
albumItems = append(albumItems, maxAlbumItem{Type: "photo", URL: a.Payload.Url})
|
||
}
|
||
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)
|
||
albumItems = append(albumItems, maxAlbumItem{Type: "video", URL: a.Payload.Url})
|
||
}
|
||
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", ""})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если для этого чата уже есть сообщения в очереди — не отправляем напрямую,
|
||
// чтобы не нарушить порядок доставки. Сразу ставим в очередь.
|
||
if b.hasPendingForChat("max2tg", tgChatID) {
|
||
slog.Info("MAX→TG queued (pending exists)", "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||
if len(albumItems) > 1 {
|
||
b.enqueueMax2TgAlbum(chatID, tgChatID, body.Mid, htmlCaption, albumItems, pm)
|
||
} else {
|
||
b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, pm)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Отправляем фото/видео как альбом (если их несколько — 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. Единый helper (качает байты + уникальные attach-имена).
|
||
// На фейле НЕ спамим чат — sendErr → outer уведёт весь альбом в очередь (album-aware).
|
||
msgIDs, err := b.sendMaxAlbumToTg(ctx, tgChatID, albumItems, htmlCaption, pm, threadID, replyToID)
|
||
if err != nil {
|
||
slog.Error("MAX→TG album send failed", "err", err)
|
||
sendErr = err
|
||
} 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\".", 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
|
||
}
|
||
if useHTML {
|
||
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlCaption, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID})
|
||
} else {
|
||
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID})
|
||
}
|
||
}
|
||
|
||
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, isCrosspost)
|
||
}
|
||
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(
|
||
"Не удалось переслать сообщение: основной топик (General) закрыт.\nОткройте General в настройках группы или сделайте бота админом.")
|
||
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, isCrosspost)
|
||
return
|
||
}
|
||
|
||
parseMode := ""
|
||
if useHTML {
|
||
parseMode = "HTML"
|
||
}
|
||
if isChatUnavailable(errStr) {
|
||
// Бот удалён/заблокирован/лишён прав в TG-чате → связку на паузу + DM
|
||
// владельцу. Не ретраим (очередь всё равно дропнет перманент).
|
||
b.cbPermanentFail(ctx, tgChatID)
|
||
} else {
|
||
// Транзиент (429 rate-limit / таймаут / API): НЕ пишем в MAX-чат «не удалось /
|
||
// в очереди» на каждый пост — при массовом постинге это флудило чат десятками
|
||
// сообщений. Молча в очередь: доставится автоматически (+ ретрай 429 в tgsender).
|
||
// Альбом кладём ЦЕЛИКОМ (album-aware), иначе доставилось бы 1 фото.
|
||
if len(albumItems) > 1 {
|
||
b.enqueueMax2TgAlbum(chatID, tgChatID, body.Mid, htmlCaption, albumItems, parseMode)
|
||
} else {
|
||
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, threadID)
|
||
}
|
||
}
|